diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 0a43637..742c2e8 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -14,7 +14,7 @@ Keyboard shortcuts in edit mode: arrow keys move, Shift+arrows resize, ⌘D or C ## 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**. - **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. @@ -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. +### 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 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. +## 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 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. diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 0b59878..3271767 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "1.6.1" +version: "1.7.0" slug: dashboard_studio 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 diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py index d6c36fd..7c5b643 100644 --- a/dashboard_studio/server/main.py +++ b/dashboard_studio/server/main.py @@ -107,6 +107,40 @@ async def index(request): 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): 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.router.add_get("/", index) 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_put("/api/dashboards", put_dashboards) app.router.add_get("/api/camera/{entity_id}", camera) diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py index 0b761ef..baad578 100644 --- a/dashboard_studio/server/mock_ha.py +++ b/dashboard_studio/server/mock_ha.py @@ -74,7 +74,8 @@ class MockHA: entity("script.good_morning", "off", friendly_name="Good Morning"), entity("weather.home", "partlycloudy", friendly_name="Home", temperature=13, humidity=71, wind_speed=14), 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("calendar.family", "off", friendly_name="Family"), entity("calendar.work", "on", friendly_name="Work"), diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index 2dc2431..82303ce 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -19,6 +19,7 @@ const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } }; const prefersDark = () => matchMedia("(prefers-color-scheme: dark)").matches; + const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches; // ------------------------------------------------------------ HA connection class HA { @@ -131,14 +132,24 @@ // ------------------------------------------------------------ themes // "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 = { 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 }, Dark: { bg: "#0f0f11", tile: "#1c1c1e", text: "#f5f5f7", text2: "#a1a1a6", accent: "#0a84ff", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", shadow: false, blur: 0 }, Nord: { bg: "#2e3440", tile: "#3b4252", text: "#eceff4", text2: "#c2c9d6", accent: "#88c0d0", separator: "rgba(216,222,233,0.14)", fill: "rgba(216,222,233,0.12)", lit: "#f3e3bf", shadow: false, blur: 0 }, Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 }, - 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 }; @@ -170,6 +181,7 @@ 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; 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: "" } }; } @@ -233,13 +245,29 @@ } // ------------------------------------------------------------ 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 editMode = lsGet("ds.edit") === "1"; let selectedId = null; let activeTabId = null; /** The tab whose widgets are on screen. */ 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 = []; let lastMutKey = null, lastMutAt = 0; const caches = {}; // per-widget runtime cache (graph history) @@ -299,7 +327,7 @@ } // ------------------------------------------------------------ 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() { const t = current.theme, s = stageEl.style; @@ -312,6 +340,8 @@ 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}`); 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`); 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"); @@ -473,9 +503,17 @@ if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`); 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; const def = Widgets[w.type]; if (!def) { el.innerHTML = `
${tr("Unknown widget “{0}”.", esc(w.type))}
`; return; } @@ -492,11 +530,11 @@ } let html; try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `
${tr("This widget couldn’t be drawn.")}
`; } - const hiddenNow = editMode && !isVisible(w); + const hiddenNow = editMode && !child && !isVisible(w); el.classList.toggle("cond-hidden", hiddenNow); el.innerHTML = `
${html}
` + (hiddenNow ? `
${Icons.svg("eye-off")}${tr("Hidden now")}
` : "") + - (editMode ? `
` : ""); + (editMode && !child ? `
` : ""); 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); } @@ -504,6 +542,7 @@ function entitiesOf(w) { const def = Widgets[w.type]; if (!def) return []; + if (def.container) return (w.cfg.children || []).flatMap(entitiesOf); if (def.entities) return def.entities(w.cfg, ha); return w.cfg.entity ? [w.cfg.entity] : []; } @@ -623,10 +662,21 @@ function renderInspector() { const el = $("#inspector"); 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()} +
${Icons.svg(cdef.icon)} ${esc(tr(cdef.label))}
+ ${cdef.fields.filter((f) => !f.when || f.when(ch.cfg)).map((f) => fieldHTML(f, ch.cfg[f.k], "cfg")).join("")} +
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, ch.style?.[f.k], "style")).join("")}
+
`; + return; + } if (w) { const def = Widgets[w.type]; el.innerHTML = `${datalists()}
${Icons.svg(def.icon)} ${esc(tr(def.label))}
${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")} + ${def.container ? childrenHTML(w) : ""}
${tr("Size and position")}
${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `
`).join("")}
${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 `
${tr("Cards")}
+ ${kids.length ? "" : `

${tr("Add the cards this container should hold.")}

`} +
${kids.map((c, i) => `
+ + + +
`).join("")}
+
+
`; + } + + 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) { const vis = w.visibility || { mode: "all", rules: [] }; const common = ["on", "off", "running", "idle", "playing", "paused", "home", "not_home", "unavailable"]; @@ -772,8 +856,8 @@ location.reload(); return; } - const w = tab().widgets.find((x) => x.id === selectedId); - const key = `${selectedId}|${path}|${k}|${idx ?? ""}`; + const w = editTarget(); + const key = `${w?.id}|${path}|${k}|${idx ?? ""}`; if (idx === "new") { if (!isChange || !v) return; @@ -807,7 +891,7 @@ else if (path === "dash") { current.name = v; renderDashSelect(); } }, 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; if (isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON")) 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); if (!src) return; const copy = { ...clone(src), id: uid() }; + (copy.cfg.children || []).forEach((ch) => (ch.id = uid())); Object.assign(copy, findSpot(copy.w, copy.h)); mutate(() => tab().widgets.push(copy)); select(copy.id); @@ -883,6 +968,7 @@ } function select(id) { + if (id !== selectedId) childIndex = null; selectedId = id; gridEl.querySelectorAll(".w").forEach((el) => el.classList.toggle("selected", el.dataset.id === id)); renderInspector(); @@ -977,14 +1063,22 @@ 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 === "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; - renderWidget(w); - gridEl.querySelector(`.w[data-id="${w.id}"] [data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus(); + renderWidget(w, box, { child: box.classList.contains("child") }); + 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 === "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 = {}; 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); } @@ -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) => `` + store.dashboards.map((d) => ``).join(""); + const devRow = (id, d, mine) => `
+ + + + ${mine ? "" : ``}
`; + const others = Object.entries(a.devices).filter(([id]) => id !== DEVICE_ID); + const html = `

${tr("Who sees which dashboard")}

+

${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.")}

+
${tr("This device")}
${devRow(DEVICE_ID, a.devices[DEVICE_ID], true)} + ${others.length ? `
${tr("Other devices")}
${others.map(([id, d]) => devRow(id, d, false)).join("")}` : ""} +
${tr("People")}
+ ${people.size ? [...people].map(([id, name]) => `
${esc(name)}${me?.id === id ? ` (${tr("you")})` : ""} +
`).join("") : `

${tr("People appear here once they have opened Dashboard Studio, or when they have a person entity.")}

`} + `; + 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) { const el = document.createElement("div"); el.className = "toast" + (err ? " err" : ""); @@ -1219,6 +1357,8 @@ 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 === "starter") { if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } const d = starterDashboard(); @@ -1294,6 +1434,10 @@ mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false }); 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]"); if (ruleAdd || ruleDel) { const w = tab().widgets.find((x) => x.id === selectedId); @@ -1416,6 +1560,7 @@ const r = await fetch(new URL("api/dashboards", BASE)); store = await r.json(); 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); } const firstReady = new Promise((res) => { @@ -1427,12 +1572,16 @@ if (!store.dashboards?.length) { await firstReady; - store = { version: 1, dashboards: [starterDashboard()] }; + store = { version: 1, dashboards: [starterDashboard()], assign: { devices: {}, users: {} } }; save(); } + me = await api("api/whoami").catch(() => null); const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1]; - setCurrent(hashId || lsGet("ds.current")); - if (location.hash.includes("kiosk")) enterKiosk({ fullscreen: false }); + // 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 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(); diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index 32b9d2f..62bc32a 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -245,6 +245,19 @@ "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}. 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", }; diff --git a/dashboard_studio/web/icons.js b/dashboard_studio/web/icons.js index 2baada9..4b909ba 100644 --- a/dashboard_studio/web/icons.js +++ b/dashboard_studio/web/icons.js @@ -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", 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", + 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", 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", diff --git a/dashboard_studio/web/index.html b/dashboard_studio/web/index.html index e1984dc..5b89789 100644 --- a/dashboard_studio/web/index.html +++ b/dashboard_studio/web/index.html @@ -36,6 +36,7 @@ +
diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index 1614802..c7609ac 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -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-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } .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 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%; } @@ -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); } .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); } -.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 */ 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::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: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; } .frame { width: 100%; height: 100%; border: 0; 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 */ .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.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-big { font-size: 22px; font-weight: 700; } .appl-big .wt { font-size: 14px; font-weight: 400; } .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 { 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; } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index 41201fe..563738f 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -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) => `
`; + const noKids = (c) => `
${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}
`; + + 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 `
${c.title ? `
${esc(c.title)}
` : ""} +
${kids.map(slot).join("")}
+ +
`; + }, + 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 + ? `
${kids.map((_, i) => ``).join("")}
` : ""; + return `
${kids.map(slot).join("")}
${dots}
`; + }, + 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.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap }; })();