commit e39a3d93f71f957680ea91cd81cede42ea4ad44a Author: veenm Date: Sun Oct 25 22:02:23 2026 +0100 Dashboard Studio 1.4.0: drag-and-drop dashboards for Home Assistant A Home Assistant app with its own sidebar tab: free-form grid editor, live entity widgets (lights, climate, car, calendar, Immich photos, Afvalwijzer waste collection and more), themes, tabs with a side menu, kiosk mode and English/Dutch translations. Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a206a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +data/ +.venv/ +.claude/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..d530075 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Dashboard Studio for Home Assistant + +A Home Assistant app (formerly called an add-on) that adds a **Dashboard Studio** tab to the sidebar. In that tab you build dashboards on a free-form grid with drag-and-drop, live entity widgets and full theme control. + +## Install + +1. In Home Assistant, go to **Settings → Apps → App store**, open the **⋮** menu → **Repositories**, and add `https://git.melvanveen.nl/veenm/ha-dashboard-studio`. +2. Find **Dashboard Studio** in the store, install it, start it, and turn on **Show in sidebar**. +3. Updates appear in the App store automatically whenever the version in `dashboard_studio/config.yaml` goes up. + +**Local install, no GitHub:** copy the `dashboard_studio` folder into the `/addons` share of your Home Assistant host (for example with the Samba or SSH app). Then go to **Settings → Apps → App store → ⋮ → Check for updates**. It appears under **Local apps**. + +The first build happens on your Home Assistant machine and takes a minute or two. + +## How it works + +- `dashboard_studio/server/main.py` is a small aiohttp server. It is reachable only through Home Assistant ingress, so only logged-in Home Assistant users can reach it. It stores dashboards in `/data` and relays the Home Assistant websocket API using the Supervisor token. Your browser never needs a long-lived access token. +- `dashboard_studio/web/` is the editor and runtime, written in plain JavaScript with no build step. `widgets.js` contains the widget library. To add a widget type, add an entry there with `fields` and a `render` function. + +## Local development + +Without a `SUPERVISOR_TOKEN`, the server runs against a built-in mock Home Assistant with fake lights, sensors, a thermostat and other entities: + +```bash +pip install aiohttp && python3 dashboard_studio/server/main.py +``` + +Then open http://localhost:8099. The Photos widget also has fake Immich albums in this mode. To try your real Immich server, set `IMMICH_URL` and `IMMICH_API_KEY` before starting the server. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md new file mode 100644 index 0000000..3b76ca0 --- /dev/null +++ b/dashboard_studio/DOCS.md @@ -0,0 +1,100 @@ +# Dashboard Studio + +A drag-and-drop dashboard builder that lives in its own sidebar tab. + +## Using it + +1. Open **Dashboard Studio** in the Home Assistant sidebar. +2. Press **Edit**. Pick widgets from the left panel, drag them anywhere on the grid, and resize from the bottom-right corner. Other widgets move out of the way. +3. Set up the selected widget in the right panel: entity, labels, icon, thresholds, colors. +4. With nothing selected, the right panel holds dashboard settings: theme, tile style, colors, background image, grid and kiosk options. +5. Press **Done**. Changes save automatically. + +Keyboard shortcuts in edit mode: arrow keys move, Shift+arrows resize, ⌘D or Ctrl+D duplicates, Delete removes, ⌘Z or Ctrl+Z undoes, and ⇧⌘Z or Ctrl+Shift+Z redoes. + +## 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*. +- **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. + +## Widgets + +Heading, entity tile, big value (with warning and critical levels), gauge, light (on/off and brightness), thermostat, history graph (several sensors), clock, weather, entity list, button (runs any Home Assistant action with JSON data), media player, **car**, **calendar**, camera, text (with `{{ sensor.x }}` live values), **photos** and web page. + +### Photos and Immich + +The Photos widget shows one picture or a slideshow that fades between photos. Pick a source: + +- **Links:** paste one or more image URLs, one per line. +- **Immich:** tick one or more albums. Both your own albums and albums shared with you appear. + +To use Immich, open this app's **Configuration** tab in Home Assistant. Enter your Immich URL (for example `http://192.168.1.20:2283`) and an API key, which you create in Immich under **Account Settings → API Keys**. Restart the app afterwards. The key stays on the server: browsers only receive the photos, never the key. You can also set how often the photo changes, shuffle the order, and show the date each photo was taken. + +### Calendar + +Add one or more `calendar.` entities to see an agenda of the coming days, grouped under Today, Tomorrow and then dates. Each calendar gets its own color bar. When you show several calendars, each event is also labelled with its calendar's name, so the color isn't the only clue. An event that is happening right now is marked **Now**. It works with every calendar integration in Home Assistant, such as Google, CalDAV and local calendars. Events refresh every few minutes. + +### Waste collection (Afvalwijzer) + +This card works with the [Afvalwijzer](https://github.com/xirixiz/homeassistant-afvalwijzer) integration. Once that integration is set up for your address, the card finds its waste sensors by itself, so there's nothing to configure. To show only some types, pick the sensors under **Waste sensors**. + +- **Next** layout: the coming pickup large, with every waste type collected that day as a colored bin. When the pickup is tomorrow, it adds a reminder to put the bin out tonight. Later pickups follow in a list. +- **List** layout: every pickup as a row with its type, "Tomorrow" or "In 6 days", and the date. + +Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and so on), and each bin is always named, so the color is never the only clue. Types with no pickup planned are hidden. You can limit the card to a number of pickups, or to pickups within a number of days. + +### Car + +Point the car widget at whichever entities your car integration provides. Each one is optional: + +| Field | Typical entity | +| --- | --- | +| Battery or fuel level (%) | `sensor._battery` | +| Range | `sensor._range` | +| Charging | `binary_sensor._charging` | +| Charging power | `sensor._charging_power` | +| Plugged in | `binary_sensor._plug` | +| Charge limit (%) | `number._charge_limit` | +| Doors | `lock._doors` | +| Climate | `climate._climate` | +| Location | `device_tracker._location` | +| Odometer | `sensor._odometer` | + +The widget shows the level as a bar with the charge limit marked, the range, and a status line (charging and its power, plugged in, parked at home, or away). It also has buttons to lock or unlock the doors and to start climate. You can add a car image by URL. Images you put in Home Assistant's `www` folder are available at `/local/…`. + +## Tabs and side menu + +A dashboard can have several tabs, each with its own widgets, for example *Overview*, *Household* and *Cameras*. To set them up, deselect all widgets in edit mode and use the **Tabs** section of the dashboard settings: + +- **Add tab** creates a tab and turns on the menu. +- Each tab has a name and an icon (an icon name or an emoji). Use the arrows to reorder tabs and the bin to delete one; ⌘Z undoes this. +- **Show tabs in a side menu** turns the menu on or off. + +The menu sits on the left on computers, becomes a narrow rail on tablets and while editing, and turns into a tab bar at the bottom on phones. To move a widget to another tab, select it and change **Tab** in the inspector. The menu also shows in kiosk mode. + +## Kiosk mode + +Press **Kiosk** in the toolbar, or open the dashboard with `#kiosk` at the end of its address. Kiosk mode: + +- hides the toolbar and editing panels, and shows no buttons of its own +- keeps the screen awake, in browsers that support it +- hides the pointer after a few seconds of no movement +- can show a dimmed clock screen saver after a number of minutes (set this in the dashboard's **Kiosk** settings) + +**Leaving kiosk mode:** tap the top-right corner of the screen 5 times within a couple of seconds. A short prompt appears with **Leave kiosk**, **Full screen** and **Stay**. If nobody answers, it closes by itself after 8 seconds. Taps in that corner never press the widget underneath. On a keyboard, Escape opens the same prompt. You can move the exit gesture to another corner in the dashboard's **Kiosk** settings. A short hint shows when kiosk mode starts, then disappears. + +On a wall tablet, use **New tab** in the kiosk settings. It opens the dashboard on its own, without the Home Assistant sidebar, and full screen works there. + +## Language + +Dashboard Studio is available in English and Dutch (Nederlands). By default it follows the language you chose in your Home Assistant profile, or your browser's language if that isn't set. To choose a language for one device, such as a wall tablet, deselect all widgets in edit mode and use **Language on this device** at the bottom of the dashboard settings. The app's Configuration tab in Home Assistant is translated as well. + +## Several dashboards + +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. + +## Data + +Dashboards are stored in the app's `/data/dashboards.json`, so they are included in Home Assistant backups. diff --git a/dashboard_studio/Dockerfile b/dashboard_studio/Dockerfile new file mode 100644 index 0000000..9cb062d --- /dev/null +++ b/dashboard_studio/Dockerfile @@ -0,0 +1,12 @@ +ARG BUILD_FROM +FROM ${BUILD_FROM} + +RUN apk add --no-cache python3 py3-aiohttp + +WORKDIR /app +COPY server/ /app/server/ +COPY web/ /app/web/ +COPY run.sh /run.sh +RUN chmod a+x /run.sh + +CMD ["/run.sh"] diff --git a/dashboard_studio/build.yaml b/dashboard_studio/build.yaml new file mode 100644 index 0000000..e5b8bc0 --- /dev/null +++ b/dashboard_studio/build.yaml @@ -0,0 +1,4 @@ +build_from: + aarch64: ghcr.io/home-assistant/aarch64-base:3.20 + amd64: ghcr.io/home-assistant/amd64-base:3.20 + armv7: ghcr.io/home-assistant/armv7-base:3.20 diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml new file mode 100644 index 0000000..fcdd577 --- /dev/null +++ b/dashboard_studio/config.yaml @@ -0,0 +1,24 @@ +name: Dashboard Studio +version: "1.4.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 +arch: + - aarch64 + - amd64 + - armv7 +init: false +startup: application +boot: auto +ingress: true +ingress_port: 8099 +ingress_stream: true +panel_icon: mdi:view-dashboard-edit +panel_title: Dashboard Studio +homeassistant_api: true +options: + immich_url: "" + immich_api_key: "" +schema: + immich_url: str? + immich_api_key: password? diff --git a/dashboard_studio/run.sh b/dashboard_studio/run.sh new file mode 100755 index 0000000..e7a05d4 --- /dev/null +++ b/dashboard_studio/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/with-contenv bashio +# with-contenv restores the container environment (including SUPERVISOR_TOKEN), +# which the s6 init in Home Assistant base images otherwise leaves out. +bashio::log.info "Starting Dashboard Studio" +exec python3 -u /app/server/main.py diff --git a/dashboard_studio/server/immich.py b/dashboard_studio/server/immich.py new file mode 100644 index 0000000..f7d5a3f --- /dev/null +++ b/dashboard_studio/server/immich.py @@ -0,0 +1,159 @@ +"""Read-only Immich access for the Photos widget. + +The API key stays on the server: the browser only ever sees album names, +asset ids and image bytes proxied through these routes. +""" + +import hashlib +import logging +import re +import time + +import aiohttp +from aiohttp import web + +LOG = logging.getLogger("dashboard_studio.immich") +ID_RE = re.compile(r"^[0-9a-fA-F-]{8,64}$") +CACHE_SECONDS = 600 +NOT_CONFIGURED = "Immich isn’t set up. Add its URL and an API key on the app’s Configuration tab." + + +class Immich: + def __init__(self, url, api_key, mock=False): + self.url = (url or "").rstrip("/") + self.key = api_key or "" + self.mock = mock and not (self.url and self.key) + self.session = None + self._assets = {} # album id -> (fetched_at, [assets]) + + @property + def configured(self): + return self.mock or bool(self.url and self.key) + + async def start(self): + self.session = aiohttp.ClientSession( + headers={"x-api-key": self.key, "Accept": "application/json"}, + timeout=aiohttp.ClientTimeout(total=20), + ) + + async def close(self): + if self.session: + await self.session.close() + + async def _json(self, method, path, **kwargs): + async with self.session.request(method, f"{self.url}/api{path}", **kwargs) as resp: + if resp.status in (401, 403): + raise web.HTTPBadGateway(text="Immich rejected the API key. Check it on the app’s Configuration tab.") + if resp.status >= 400: + raise web.HTTPBadGateway(text=f"Immich returned HTTP {resp.status} for {path}.") + return await resp.json() + + # ------------------------------------------------------------ data + + async def albums(self): + if self.mock: + return [ + {"id": "11111111-aaaa-4bbb-8ccc-000000000001", "name": "Summer holiday", "count": 6}, + {"id": "11111111-aaaa-4bbb-8ccc-000000000002", "name": "Family", "count": 4}, + {"id": "11111111-aaaa-4bbb-8ccc-000000000003", "name": "Garden", "count": 3}, + ] + rows = await self._json("GET", "/albums") + shared = await self._json("GET", "/albums", params={"shared": "true"}) + seen, out = set(), [] + for a in rows + shared: + if a["id"] in seen: + continue + seen.add(a["id"]) + out.append({"id": a["id"], "name": a.get("albumName") or "Untitled", "count": a.get("assetCount", 0)}) + return sorted(out, key=lambda a: a["name"].lower()) + + async def album_assets(self, album_id): + cached = self._assets.get(album_id) + if cached and time.time() - cached[0] < CACHE_SECONDS: + return cached[1] + if self.mock: + n = int(album_id[-1]) + 2 + assets = [{"id": f"{album_id[:-4]}{i:04d}", "date": f"2026-0{(i % 9) + 1}-1{i % 9}T12:00:00Z"} for i in range(n * 2)] + else: + assets = await self._fetch_album_assets(album_id) + self._assets[album_id] = (time.time(), assets) + return assets + + async def _fetch_album_assets(self, album_id): + album = await self._json("GET", f"/albums/{album_id}") + items = album.get("assets") + if not items and album.get("assetCount"): + # Newer Immich versions leave assets out of the album response; search instead. + items, page = [], 1 + while page and len(items) < 5000: + res = await self._json("POST", "/search/metadata", json={"albumIds": [album_id], "type": "IMAGE", "size": 1000, "page": page}) + block = res.get("assets", {}) + items += block.get("items", []) + page = block.get("nextPage") + page = int(page) if page else None + return [ + {"id": a["id"], "date": a.get("localDateTime") or a.get("fileCreatedAt")} + for a in items or [] + if a.get("type", "IMAGE") == "IMAGE" and not a.get("isTrashed") + ] + + async def preview(self, asset_id): + if self.mock: + return self._mock_image(asset_id), "image/svg+xml" + async with self.session.get(f"{self.url}/api/assets/{asset_id}/thumbnail", params={"size": "preview"}) as resp: + if resp.status >= 400: + raise web.HTTPBadGateway(text=f"Immich returned HTTP {resp.status} for that photo.") + return await resp.read(), resp.content_type or "image/jpeg" + + @staticmethod + def _mock_image(asset_id): + h = hashlib.md5(asset_id.encode()).hexdigest() + c1, c2 = f"#{h[:6]}", f"#{h[6:12]}" + return f""" + + + +Mock photo {asset_id[-4:]}""".encode() + + # ------------------------------------------------------------ routes + + def routes(self): + def guard(): + if not self.configured: + raise web.HTTPServiceUnavailable(text=NOT_CONFIGURED) + + async def albums(request): + guard() + try: + return web.json_response(await self.albums()) + except aiohttp.ClientError as err: + LOG.error("Immich albums: %s", err) + raise web.HTTPBadGateway(text="Couldn’t reach Immich. Check the URL on the app’s Configuration tab.") + + async def assets(request): + guard() + album_id = request.match_info["album_id"] + if not ID_RE.match(album_id): + raise web.HTTPBadRequest() + try: + return web.json_response(await self.album_assets(album_id)) + except aiohttp.ClientError as err: + LOG.error("Immich album %s: %s", album_id, err) + raise web.HTTPBadGateway(text="Couldn’t reach Immich.") + + async def preview(request): + guard() + asset_id = request.match_info["asset_id"] + if not ID_RE.match(asset_id): + raise web.HTTPBadRequest() + try: + body, ctype = await self.preview(asset_id) + except aiohttp.ClientError: + raise web.HTTPBadGateway(text="Couldn’t reach Immich.") + return web.Response(body=body, content_type=ctype, headers={"Cache-Control": "private, max-age=86400"}) + + return [ + web.get("/api/immich/albums", albums), + web.get("/api/immich/albums/{album_id}/assets", assets), + web.get("/api/immich/assets/{asset_id}/preview", preview), + ] diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py new file mode 100644 index 0000000..a4ee1b5 --- /dev/null +++ b/dashboard_studio/server/main.py @@ -0,0 +1,210 @@ +"""Dashboard Studio backend. + +Serves the editor UI over Home Assistant ingress, stores dashboards in /data, +and proxies the Home Assistant websocket API using the Supervisor token so the +browser never needs its own long-lived access token. + +Without a SUPERVISOR_TOKEN (local development) it runs against a mock Home +Assistant with a handful of fake entities. +""" + +import asyncio +import json +import logging +import os +from pathlib import Path + +import aiohttp +from aiohttp import web + +from immich import Immich +from mock_ha import MockHA + +LOG = logging.getLogger("dashboard_studio") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +SUPERVISOR_TOKEN = os.environ.get("SUPERVISOR_TOKEN") +# Inside Home Assistant the Supervisor always writes /data/options.json. Fake data is +# only for local development, never a silent fallback on a real install. +IN_HOME_ASSISTANT = Path("/data/options.json").exists() +MOCK = not SUPERVISOR_TOKEN and not IN_HOME_ASSISTANT +HA_WS_URL = "ws://supervisor/core/websocket" +HA_API_URL = "http://supervisor/core/api" +INGRESS_PROXY_IP = "172.30.32.2" + +WEB_DIR = Path(__file__).resolve().parent.parent / "web" +DATA_DIR = Path("/data") if Path("/data").is_dir() else Path(__file__).resolve().parent.parent / "data" +STORE_FILE = DATA_DIR / "dashboards.json" +PORT = int(os.environ.get("PORT", "8099")) + + +def load_options(): + """App options from the Configuration tab (/data/options.json), or env vars when developing.""" + opts = {} + path = Path("/data/options.json") + if path.exists(): + try: + opts = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + LOG.exception("Could not read %s", path) + return { + "immich_url": opts.get("immich_url") or os.environ.get("IMMICH_URL", ""), + "immich_api_key": opts.get("immich_api_key") or os.environ.get("IMMICH_API_KEY", ""), + } + + +OPTIONS = load_options() + + +@web.middleware +async def ingress_only(request, handler): + # Only the Supervisor ingress proxy may talk to us; it has already + # authenticated the Home Assistant user. + if not MOCK and request.remote != INGRESS_PROXY_IP: + return web.Response(status=403, text="Access only via Home Assistant ingress") + response = await handler(request) + # Revalidate the UI files so an add-on update shows up immediately. + if isinstance(response, web.FileResponse): + response.headers.setdefault("Cache-Control", "no-cache") + return response + + +def load_store(): + if STORE_FILE.exists(): + try: + return json.loads(STORE_FILE.read_text()) + except (OSError, json.JSONDecodeError): + LOG.exception("Could not read %s, starting empty", STORE_FILE) + return {"version": 1, "dashboards": []} + + +def save_store(data): + DATA_DIR.mkdir(parents=True, exist_ok=True) + tmp = STORE_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2)) + tmp.replace(STORE_FILE) + + +async def index(request): + return web.FileResponse(WEB_DIR / "index.html") + + +async def get_dashboards(request): + return web.json_response(load_store()) + + +async def put_dashboards(request): + try: + data = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "invalid json"}, status=400) + if not isinstance(data, dict) or not isinstance(data.get("dashboards"), list): + return web.json_response({"error": "expected {dashboards: [...]}"}, status=400) + save_store(data) + return web.json_response({"ok": True}) + + +async def camera(request): + entity_id = request.match_info["entity_id"] + if not entity_id.startswith("camera.") or "/" in entity_id: + return web.Response(status=400) + if MOCK: + return web.Response(body=request.app["mock"].camera_svg(entity_id), content_type="image/svg+xml") + session = request.app["http"] + async with session.get( + f"{HA_API_URL}/camera_proxy/{entity_id}", + headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"}, + ) as resp: + body = await resp.read() + return web.Response( + status=resp.status, + body=body, + content_type=resp.content_type or "image/jpeg", + headers={"Cache-Control": "no-store"}, + ) + + +async def websocket(request): + client = web.WebSocketResponse(heartbeat=30, max_msg_size=0) + await client.prepare(request) + + if MOCK: + await request.app["mock"].serve(client) + return client + if not SUPERVISOR_TOKEN: + await client.send_json({"type": "error", "message": "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it."}) + await client.close() + return client + + session = request.app["http"] + try: + upstream = await session.ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0) + except aiohttp.ClientError as err: + LOG.error("Cannot reach Home Assistant websocket: %s", err) + await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"}) + await client.close() + return client + + try: + msg = await upstream.receive_json() # auth_required + await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN}) + msg = await upstream.receive_json() + if msg.get("type") != "auth_ok": + LOG.error("Home Assistant rejected the Supervisor token: %s", msg) + await client.send_json({"type": "error", "message": "Home Assistant auth failed"}) + return client + await client.send_json({"type": "ready", "ha_version": msg.get("ha_version")}) + + async def pump(src, dst): + async for m in src: + if m.type == aiohttp.WSMsgType.TEXT: + await dst.send_str(m.data) + elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE): + break + + tasks = [asyncio.create_task(pump(client, upstream)), asyncio.create_task(pump(upstream, client))] + _, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for t in pending: + t.cancel() + finally: + await upstream.close() + await client.close() + return client + + +async def on_startup(app): + app["http"] = aiohttp.ClientSession() + await app["immich"].start() + if MOCK: + app["mock"] = MockHA() + app["mock_task"] = asyncio.create_task(app["mock"].simulate()) + LOG.warning("No SUPERVISOR_TOKEN: running against mock Home Assistant") + + +async def on_cleanup(app): + await app["http"].close() + await app["immich"].close() + if MOCK: + app["mock_task"].cancel() + + +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/dashboards", get_dashboards) + app.router.add_put("/api/dashboards", put_dashboards) + app.router.add_get("/api/camera/{entity_id}", camera) + app.router.add_get("/ws", websocket) + app["immich"] = Immich(OPTIONS["immich_url"], OPTIONS["immich_api_key"], mock=MOCK) + app.router.add_routes(app["immich"].routes()) + app.router.add_static("/static", WEB_DIR) + app.on_startup.append(on_startup) + app.on_cleanup.append(on_cleanup) + return app + + +if __name__ == "__main__": + if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN: + LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant") + LOG.info("Dashboard Studio listening on port %s (data in %s)", PORT, DATA_DIR) + web.run_app(make_app(), port=PORT, access_log=None) diff --git a/dashboard_studio/server/mock_ha.py b/dashboard_studio/server/mock_ha.py new file mode 100644 index 0000000..fce0b08 --- /dev/null +++ b/dashboard_studio/server/mock_ha.py @@ -0,0 +1,260 @@ +"""A tiny fake of the Home Assistant websocket API for local development.""" + +import asyncio +import json +import math +import random +from datetime import datetime, timedelta, timezone + +from aiohttp import WSMsgType + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def entity(entity_id, state, **attrs): + ts = now_iso() + return { + "entity_id": entity_id, + "state": state, + "attributes": attrs, + "last_changed": ts, + "last_updated": ts, + "context": {"id": "mock"}, + } + + +class MockHA: + def __init__(self): + today = datetime.now().astimezone().date() + + def pickup(kind, days): + # Mirrors the Afvalwijzer integration: ISO date state plus collection-date attributes. + if days is None: + return entity(f"sensor.afvalwijzer_1234ab_12_{kind}", "geen", friendly_name=f"Afvalwijzer {kind}", last_update=now_iso(), + days_until_collection_date=None, is_collection_date_today=False, is_collection_date_tomorrow=False, is_collection_date_day_after_tomorrow=False) + return entity(f"sensor.afvalwijzer_1234ab_12_{kind}", (today + timedelta(days=days)).isoformat(), friendly_name=f"Afvalwijzer {kind}", last_update=now_iso(), + days_until_collection_date=days, is_collection_date_today=days == 0, is_collection_date_tomorrow=days == 1, is_collection_date_day_after_tomorrow=days == 2) + + waste = [pickup("gft", 1), pickup("pmd", 1), pickup("papier", 6), pickup("restafval", 8), pickup("textiel", 23), pickup("kerstbomen", None), + entity("sensor.afvalwijzer_1234ab_12_tomorrow", "gft, pmd", friendly_name="Afvalwijzer Morgen"), + entity("sensor.afvalwijzer_1234ab_12_next_in_days", "1", friendly_name="Afvalwijzer Dagen tot volgende ophaling")] + self.states = { + e["entity_id"]: e + for e in waste + [ + entity("light.living_room", "on", friendly_name="Living Room", brightness=180, supported_color_modes=["brightness"]), + entity("light.reading_lamp", "on", friendly_name="Reading Lamp", brightness=140, rgb_color=[255, 140, 60], supported_color_modes=["rgb"]), + entity("light.kitchen", "off", friendly_name="Kitchen", brightness=None, supported_color_modes=["brightness"]), + entity("light.bedroom", "on", friendly_name="Bedroom", brightness=90, supported_color_modes=["brightness"]), + entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"), + entity("fan.office", "off", friendly_name="Office Fan"), + entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"), + entity("sensor.outdoor_temperature", "12.8", friendly_name="Outdoor Temperature", unit_of_measurement="°C", device_class="temperature"), + entity("sensor.living_room_humidity", "48", friendly_name="Living Room Humidity", unit_of_measurement="%", device_class="humidity"), + entity("sensor.power_usage", "640", friendly_name="Power Usage", unit_of_measurement="W", device_class="power"), + entity("sensor.solar_production", "1820", friendly_name="Solar Production", unit_of_measurement="W", device_class="power"), + entity("sensor.phone_battery", "76", friendly_name="Phone Battery", unit_of_measurement="%", device_class="battery"), + entity("binary_sensor.front_door", "off", friendly_name="Front Door", device_class="door"), + entity("binary_sensor.hallway_motion", "off", friendly_name="Hallway Motion", device_class="motion"), + entity("climate.thermostat", "heat", friendly_name="Thermostat", current_temperature=20.5, temperature=21, min_temp=7, max_temp=30, hvac_modes=["off", "heat", "auto"]), + entity("cover.garage_door", "closed", friendly_name="Garage Door"), + entity("lock.front_door", "locked", friendly_name="Front Door Lock"), + entity("media_player.living_room_speaker", "playing", friendly_name="Living Room Speaker", media_title="Blue in Green", media_artist="Miles Davis", volume_level=0.35), + entity("scene.movie_night", "2026-10-24T20:00:00+00:00", friendly_name="Movie Night"), + 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("input_boolean.guest_mode", "off", friendly_name="Guest Mode"), + entity("calendar.family", "off", friendly_name="Family"), + entity("calendar.work", "on", friendly_name="Work"), + entity("sensor.model_y_battery", "68", friendly_name="Model Y Battery", unit_of_measurement="%", device_class="battery"), + entity("sensor.model_y_range", "341", friendly_name="Model Y Range", unit_of_measurement="km", device_class="distance"), + entity("binary_sensor.model_y_charging", "on", friendly_name="Model Y Charging", device_class="battery_charging"), + entity("sensor.model_y_charging_power", "7.4", friendly_name="Model Y Charging Power", unit_of_measurement="kW", device_class="power"), + entity("binary_sensor.model_y_plug", "on", friendly_name="Model Y Plug", device_class="plug"), + entity("number.model_y_charge_limit", "80", friendly_name="Model Y Charge Limit", unit_of_measurement="%"), + entity("lock.model_y_doors", "locked", friendly_name="Model Y Doors"), + entity("climate.model_y_climate", "off", friendly_name="Model Y Climate", current_temperature=14, temperature=21, hvac_modes=["off", "heat_cool"]), + entity("device_tracker.model_y_location", "home", friendly_name="Model Y Location"), + entity("sensor.model_y_odometer", "24816", friendly_name="Model Y Odometer", unit_of_measurement="km", device_class="distance"), + ] + } + self.clients = set() + + # -- state handling --------------------------------------------------- + + def set_state(self, entity_id, state=None, **attrs): + old = self.states[entity_id] + new = json.loads(json.dumps(old)) + if state is not None and state != old["state"]: + new["state"] = state + new["last_changed"] = now_iso() + new["attributes"].update(attrs) + new["last_updated"] = now_iso() + self.states[entity_id] = new + event = {"event_type": "state_changed", "data": {"entity_id": entity_id, "old_state": old, "new_state": new}, "time_fired": now_iso()} + for ws, sub_ids in list(self.clients): + for sid in sub_ids: + asyncio.ensure_future(self._safe_send(ws, {"id": sid, "type": "event", "event": event})) + + async def _safe_send(self, ws, payload): + try: + await ws.send_json(payload) + except Exception: # client went away + pass + + def call_service(self, domain, service, data): + targets = data.get("entity_id") or (data.get("target") or {}).get("entity_id") or [] + if isinstance(targets, str): + targets = [targets] + for eid in targets: + if eid not in self.states: + continue + st = self.states[eid]["state"] + if eid.startswith("climate.") and service in ("turn_on", "turn_off", "toggle"): + on = service == "turn_on" or (service == "toggle" and st == "off") + self.set_state(eid, (self.states[eid]["attributes"].get("hvac_modes") or ["off", "heat"])[-1] if on else "off") + elif service in ("turn_on", "turn_off", "toggle"): + on = service == "turn_on" or (service == "toggle" and st != "on") + extra = {} + if eid.startswith("light."): + if "brightness" in data: + extra["brightness"] = data["brightness"] + elif "brightness_pct" in data: + extra["brightness"] = round(data["brightness_pct"] * 2.55) + elif on and not self.states[eid]["attributes"].get("brightness"): + extra["brightness"] = 255 + if not on: + extra["brightness"] = None + self.set_state(eid, "on" if on else "off", **extra) + elif service == "set_temperature": + self.set_state(eid, None, temperature=data.get("temperature")) + elif service == "set_hvac_mode": + self.set_state(eid, data.get("hvac_mode")) + elif service in ("open_cover", "close_cover"): + self.set_state(eid, "open" if service == "open_cover" else "closed") + elif service == "toggle_cover" or (domain == "cover" and service == "toggle"): + self.set_state(eid, "closed" if st == "open" else "open") + elif service in ("lock", "unlock"): + self.set_state(eid, "locked" if service == "lock" else "unlocked") + elif service == "media_play_pause": + self.set_state(eid, "paused" if st == "playing" else "playing") + elif service == "volume_set": + self.set_state(eid, None, volume_level=data.get("volume_level")) + elif domain in ("scene", "script") and service in ("turn_on",): + self.set_state(eid, now_iso() if domain == "scene" else "off") + + def calendar_events(self, entity_ids): + """A repeatable week of events relative to today.""" + today = datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0) + plan = { + "calendar.family": [(0, 18, 30, 90, "Dinner with grandparents", "Home"), (1, None, 0, 0, "Bin collection", ""), (2, 16, 0, 60, "Swimming lessons", "Sports centre"), (5, None, 0, 0, "Emma’s birthday", ""), (6, 10, 0, 120, "Market", "Town square")], + "calendar.work": [(0, 9, 0, 30, "Stand-up", ""), (0, 14, 0, 60, "Design review", "Room 3"), (1, 11, 0, 45, "1:1 with Sam", ""), (3, 13, 30, 90, "Quarterly planning", "Main office")], + } + out = {} + for eid in entity_ids: + events = [] + for day, hour, minute, dur, summary, location in plan.get(eid, []): + d = today + timedelta(days=day) + if hour is None: + events.append({"start": d.date().isoformat(), "end": (d + timedelta(days=1)).date().isoformat(), "summary": summary}) + else: + start = d.replace(hour=hour, minute=minute) + events.append({"start": start.isoformat(), "end": (start + timedelta(minutes=dur)).isoformat(), "summary": summary, "location": location}) + out[eid] = {"events": events} + return out + + def history(self, entity_ids, start): + out = {} + start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) + span = (datetime.now(timezone.utc) - start_dt).total_seconds() + for eid in entity_ids: + st = self.states.get(eid) + if not st: + continue + try: + base = float(st["state"]) + except ValueError: + out[eid] = [{"s": st["state"], "lu": start_dt.timestamp()}] + continue + pts = [] + n = 96 + seed = sum(map(ord, eid)) + for i in range(n): + t = start_dt.timestamp() + span * i / (n - 1) + wave = math.sin(i / 10 + seed) * 0.08 + math.sin(i / 3.3 + seed) * 0.03 + pts.append({"s": f"{base * (1 + wave):.1f}", "lu": t}) + pts[-1]["s"] = st["state"] + out[eid] = pts + return out + + def camera_svg(self, entity_id): + stamp = datetime.now().strftime("%H:%M:%S") + return f""" + + + + +{entity_id} · {stamp} +""".encode() + + async def simulate(self): + while True: + await asyncio.sleep(3) + for eid, spread in ( + ("sensor.power_usage", 60), + ("sensor.solar_production", 80), + ("sensor.living_room_temperature", 0.1), + ("sensor.outdoor_temperature", 0.2), + ): + v = float(self.states[eid]["state"]) + random.uniform(-spread, spread) + self.set_state(eid, f"{max(v, 0):.1f}" if spread < 1 else f"{max(v, 0):.0f}") + if self.states["binary_sensor.model_y_charging"]["state"] == "on": + lvl = float(self.states["sensor.model_y_battery"]["state"]) + if lvl < 80: + self.set_state("sensor.model_y_battery", f"{lvl + 0.2:.1f}") + if random.random() < 0.15: + eid = "binary_sensor.hallway_motion" + self.set_state(eid, "off" if self.states[eid]["state"] == "on" else "on") + + # -- websocket ---------------------------------------------------------- + + async def serve(self, ws): + subs = set() + entry = (ws, frozenset()) + await ws.send_json({"type": "ready", "ha_version": "mock"}) + try: + async for msg in ws: + if msg.type != WSMsgType.TEXT: + continue + req = json.loads(msg.data) + mid, typ = req.get("id"), req.get("type") + if typ == "get_states": + await ws.send_json({"id": mid, "type": "result", "success": True, "result": list(self.states.values())}) + elif typ == "subscribe_events": + subs.add(mid) + self.clients.discard(entry) + entry = (ws, frozenset(subs)) + self.clients.add(entry) + await ws.send_json({"id": mid, "type": "result", "success": True, "result": None}) + elif typ == "call_service" and req.get("domain") == "calendar" and req.get("service") == "get_events": + ids = (req.get("target") or {}).get("entity_id") or [] + ids = [ids] if isinstance(ids, str) else ids + await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": self.calendar_events(ids)}}) + elif typ == "call_service": + data = dict(req.get("service_data") or {}) + if req.get("target"): + data.setdefault("entity_id", req["target"].get("entity_id")) + self.call_service(req["domain"], req["service"], data) + await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}}}) + elif typ == "history/history_during_period": + start = req.get("start_time") or (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat() + await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.history(req.get("entity_ids", []), start)}) + elif typ == "ping": + await ws.send_json({"id": mid, "type": "pong"}) + else: + await ws.send_json({"id": mid, "type": "result", "success": False, "error": {"code": "unknown_command", "message": typ}}) + finally: + self.clients.discard(entry) diff --git a/dashboard_studio/translations/en.yaml b/dashboard_studio/translations/en.yaml new file mode 100644 index 0000000..a795380 --- /dev/null +++ b/dashboard_studio/translations/en.yaml @@ -0,0 +1,7 @@ +configuration: + immich_url: + name: Immich URL + description: Address of your Immich server, for example http://192.168.1.20:2283. Used by the Photos widget. + immich_api_key: + name: Immich API key + description: Create one in Immich under Account Settings → API Keys. Read access to albums and assets is enough. diff --git a/dashboard_studio/translations/nl.yaml b/dashboard_studio/translations/nl.yaml new file mode 100644 index 0000000..746a4e7 --- /dev/null +++ b/dashboard_studio/translations/nl.yaml @@ -0,0 +1,7 @@ +configuration: + immich_url: + name: Immich-URL + description: Adres van je Immich-server, bijvoorbeeld http://192.168.1.20:2283. Wordt gebruikt door de widget Foto’s. + immich_api_key: + name: Immich-API-sleutel + description: Maak er een aan in Immich via Accountinstellingen → API-sleutels. Leestoegang tot albums en items is genoeg. diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js new file mode 100644 index 0000000..eff72aa --- /dev/null +++ b/dashboard_studio/web/app.js @@ -0,0 +1,1292 @@ +/* Dashboard Studio editor + runtime. */ +(function () { + const { esc, domainOf, isOn, friendly, fmtState, relTime, clockHTML, cap } = window.HAFmt; + const Widgets = window.Widgets; + const Icons = window.Icons; + const tr = window.I18n.t, LOCALE = window.I18n.locale; + /** Option labels: a direct translation when there is one, else the capitalised value. */ + const optLabel = (o) => (tr(o) !== o ? tr(o) : tr(cap(String(o).replace("-", " ")))); + const $ = (s, el = document) => el.querySelector(s); + const uid = () => Math.random().toString(36).slice(2, 10); + const clone = (o) => JSON.parse(JSON.stringify(o)); + + // Ingress serves us under /api/hassio_ingress// — keep every URL relative to that. + const BASE = new URL(location.pathname.endsWith("/") ? location.pathname : location.pathname + "/", location.origin); + + 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; + + // ------------------------------------------------------------ HA connection + class HA { + constructor() { + this.states = {}; + this.nextId = 1; + this.pending = new Map(); + this.connected = false; + this.onChange = () => {}; + this.onStatus = () => {}; + this.ready = new Promise((r) => (this._markReady = r)); + } + connect() { + const url = new URL("ws", BASE); + url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = (this.ws = new WebSocket(url)); + ws.onmessage = (ev) => this.handle(JSON.parse(ev.data)); + ws.onclose = () => { + if (this.connected) this.ready = new Promise((r) => (this._markReady = r)); + this.connected = false; + this.onStatus(false); + for (const p of this.pending.values()) p.reject?.(new Error("disconnected")); + this.pending.clear(); + setTimeout(() => this.connect(), 3000); + }; + } + send(msg, onEvent) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject, onEvent }); + this.ws.send(JSON.stringify({ ...msg, id })); + }); + } + async handle(m) { + if (m.type === "ready") { + const states = await this.send({ type: "get_states" }); + this.states = Object.fromEntries(states.map((s) => [s.entity_id, s])); + this.send({ type: "subscribe_events", event_type: "state_changed" }, (ev) => { + const { entity_id, new_state } = ev.data; + if (new_state) this.states[entity_id] = new_state; + else delete this.states[entity_id]; + this.onChange(entity_id); + }); + this.connected = true; + this._markReady(); + this.onStatus(true); + this.onChange(null); + return; + } + if (m.type === "error" && m.id == null) { + // The server closes and we reconnect every few seconds; don't repeat the same message. + if (m.message !== this.lastError || Date.now() - this.lastErrorAt > 60000) toast(m.message, true); + this.lastError = m.message; this.lastErrorAt = Date.now(); + return; + } + const p = this.pending.get(m.id); + if (!p) return; + if (m.type === "event") { p.onEvent?.(m.event); return; } + if (m.type === "result") { + if (!p.onEvent) this.pending.delete(m.id); + if (m.success) p.resolve(m.result); + else p.reject(new Error(m.error?.message || "Request failed")); + } + } + callService(domain, service, data = {}, entityId) { + if (!this.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return Promise.resolve(); } + const msg = { type: "call_service", domain, service, service_data: data }; + if (entityId) msg.target = { entity_id: entityId }; + return this.send(msg).catch((e) => toast(tr("{0} failed: {1}", `${domain}.${service}`, e.message), true)); + } + /** Events from calendar entities, via calendar.get_events with return_response. */ + async calendarEvents(entityIds, start, end) { + await this.ready; + const res = await this.send({ + type: "call_service", domain: "calendar", service: "get_events", + target: { entity_id: entityIds }, + service_data: { start_date_time: start.toISOString(), end_date_time: end.toISOString() }, + return_response: true, + }); + return res?.response || {}; + } + async history(entityIds, hours) { + await this.ready; + const res = await this.send({ + type: "history/history_during_period", + start_time: new Date(Date.now() - hours * 3600e3).toISOString(), + entity_ids: entityIds, + minimal_response: true, + no_attributes: true, + significant_changes_only: false, + }); + const out = {}; + for (const [eid, rows] of Object.entries(res || {})) { + out[eid] = rows.map((r) => [(r.lu ?? r.lc ?? 0) * 1000, Number(r.s)]); + } + return out; + } + } + + const ha = new HA(); + + // ------------------------------------------------------------ themes + // "System" keeps the adaptive light/dark tokens from style.css. + const COLOR_KEYS = ["bg", "tile", "text", "text2", "accent", "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 }, + }; + + const DEFAULT_THEME = { preset: "System", surface: "cards", radius: 20, bgImage: "", blur: 0, shadow: true }; + const DEFAULT_GRID = { cols: 12, rowHeight: 72, gap: 14, maxWidth: 1440 }; + const DEFAULT_KIOSK = { saver: 0, exitCorner: "top-right", hideCursor: true }; + + const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] }); + const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, nav: { show: false }, tabs: [newTab(tr("Overview"))] }); + + // Bring dashboards saved by older versions up to the current shape. + function migrate(d) { + const t = d.theme || {}; + if (!t.preset) { + t.preset = "Custom"; + if (t.card && !t.tile) t.tile = t.card; + if (t.border && !t.separator) t.separator = t.border; + } + d.theme = { ...DEFAULT_THEME, ...t }; + d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) }; + d.kiosk = { ...DEFAULT_KIOSK, ...(d.kiosk || {}) }; + d.nav = { show: false, ...(d.nav || {}) }; + if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }]; + delete d.widgets; + return d; + } + + function makeWidget(type, x = 0, y = 0) { + const def = Widgets[type]; + 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 = []; + return { id: uid(), type, x, y, w: def.size[0], h: def.size[1], cfg, style: { transparent: !!def.transparent, bg: "", color: "", accent: "" } }; + } + + function starterDashboard() { + const d = newDashboard(tr("Home")); + const ids = Object.keys(ha.states); + const first = (dom, n = 1) => ids.filter((e) => domainOf(e) === dom).slice(0, n); + const put = (type, x, y, w, h, cfg) => { const wd = makeWidget(type, x, y); Object.assign(wd, { w, h }); Object.assign(wd.cfg, cfg); d.tabs[0].widgets.push(wd); return wd; }; + const numeric = (e) => !isNaN(Number(ha.states[e].state)); + + put("heading", 0, 0, 8, 1, { text: tr("Home"), subtitle: tr("Everything at a glance") }); + put("clock", 8, 0, 4, 3, {}); + const weather = first("weather")[0]; + if (weather) put("weather", 0, 1, 4, 2, { entity: weather }); + const lights = first("light", 3); + if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] }); + + const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging/.test(e)).slice(0, 2); + sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e })); + const tiles = [...lights.slice(1, 2), ...first("switch", 1)]; + tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e })); + + // A car is recognisable as a lock with a sibling "_battery" sensor. + const carLock = first("lock", 20).find((l) => ha.states[`sensor.${l.split(".")[1].replace(/_[^_]+$/, "")}_battery`]); + let y = 5; + if (carLock) { + const base = carLock.split(".")[1].replace(/_[^_]+$/, ""); + const pick = (dom, suffix) => (ha.states[`${dom}.${base}_${suffix}`] ? `${dom}.${base}_${suffix}` : ""); + put("car", 0, y, 6, 3, { + name: cap(base.replace(/_/g, " ")).replace(/\b\w/g, (m) => m.toUpperCase()), + level: pick("sensor", "battery"), range: pick("sensor", "range"), charging: pick("binary_sensor", "charging"), + power: pick("sensor", "charging_power"), plug: pick("binary_sensor", "plug"), limit: pick("number", "charge_limit"), + lock: carLock, climate: pick("climate", "climate"), location: pick("device_tracker", "location"), odometer: pick("sensor", "odometer"), + }); + } + const sw = [...first("switch", 3).slice(1), ...first("input_boolean", 1), ...first("fan", 1), ...lights.slice(2)]; + if (sw.length) put("list", carLock ? 6 : 8, y, carLock ? 6 : 4, 3, { title: tr("Quick controls"), entities: sw }); + const graphSensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && ha.states[e].attributes.device_class === "temperature").slice(0, 2); + if (graphSensors.length) put("graph", 0, carLock ? y + 3 : y, carLock ? 12 : 8, 3, { entities: graphSensors, title: tr("Temperature, last 24 hours") }); + if (ids.some((e) => e.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in ha.states[e].attributes)) { + const bottom = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)); + put("waste", 0, bottom, 4, 4, {}); + } + return d; + } + + // ------------------------------------------------------------ app state + let store = { version: 1, dashboards: [] }; + 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]; + const undoStack = [], redoStack = []; + let lastMutKey = null, lastMutAt = 0; + const caches = {}; // per-widget runtime cache (graph history) + + const gridEl = $("#grid"), stageEl = $("#stage"), canvasEl = $("#canvas"), navEl = $("#tabnav"); + let metrics = { colW: 0, rh: 72, gap: 14, cols: 12, compact: false }; + + // ------------------------------------------------------------ persistence + let saveTimer = null; + function scheduleSave() { + $("#save-status").textContent = tr("Saving…"); + clearTimeout(saveTimer); + saveTimer = setTimeout(save, 700); + } + async function save() { + try { + const r = await fetch(new URL("api/dashboards", BASE), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(store) }); + if (!r.ok) throw new Error(r.status); + $("#save-status").textContent = tr("Saved"); + } catch { + $("#save-status").textContent = tr("Not saved"); + toast(tr("Couldn’t save. Your changes are kept here and will save on the next edit."), true); + } + } + + /** Apply a change to the current dashboard with undo support. + * `key` coalesces rapid edits (typing) into one undo step. */ + function mutate(fn, key, { rerender = true } = {}) { + const now = Date.now(); + if (!(key && key === lastMutKey && now - lastMutAt < 1200)) { + undoStack.push(JSON.stringify(current)); + if (undoStack.length > 100) undoStack.shift(); + redoStack.length = 0; + } + lastMutKey = key; lastMutAt = now; + fn(); + scheduleSave(); + updateUndoButtons(); + if (rerender) renderGrid(); + } + + function restore(snapshot) { + const idx = store.dashboards.findIndex((d) => d.id === current.id); + current = JSON.parse(snapshot); + store.dashboards[idx] = current; + if (!tab().widgets.some((w) => w.id === selectedId)) selectedId = null; + lastMutKey = null; + scheduleSave(); updateUndoButtons(); renderAll(); + } + function undo() { if (undoStack.length) { redoStack.push(JSON.stringify(current)); restore(undoStack.pop()); } } + function redo() { if (redoStack.length) { undoStack.push(JSON.stringify(current)); restore(redoStack.pop()); } } + function updateUndoButtons() { + $("#btn-undo").disabled = !undoStack.length; + $("#btn-redo").disabled = !redoStack.length; + } + + // ------------------------------------------------------------ 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" }; + + function applyTheme() { + const t = current.theme, s = stageEl.style; + stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain"); + stageEl.classList.add(`surface-${t.surface || "cards"}`); + for (const k of COLOR_KEYS) { + if (t.preset !== "System" && t[k]) s.setProperty(VAR_FOR[k], t[k]); + else s.removeProperty(VAR_FOR[k]); + } + 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-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"); + } + + function computeMetrics() { + const g = current.grid; + const pad = window.innerWidth < 560 ? 12 : 20; + const avail = canvasEl.clientWidth - pad * 2; + const width = g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail; + const compact = !editMode && width < 560; + const cols = compact ? 2 : Math.max(1, Number(g.cols) || 12); + const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0; + const rh = Number(g.rowHeight) || 72; + metrics = { width, cols, gap, rh, colW: (width - gap * (cols - 1)) / cols, compact }; + gridEl.style.width = `${width}px`; + gridEl.style.margin = `${editMode ? 4 : 8}px auto ${pad}px`; + } + + // Compact widths: two columns in reading order. Small widgets take one column, the rest the full width. + function positions() { + const map = {}; + if (!metrics.compact) { + for (const w of tab().widgets) map[w.id] = w; + return map; + } + const sorted = [...tab().widgets].sort((a, b) => a.y - b.y || a.x - b.x); + const small = (w) => w.w <= Math.floor(current.grid.cols / 4) && w.type !== "heading"; + let y = 0, halfOpen = null; + for (const w of sorted) { + if (small(w)) { + if (halfOpen) { map[w.id] = { x: 1, y: halfOpen.y, w: 1, h: halfOpen.h }; y = halfOpen.y + halfOpen.h; halfOpen = null; } + else { const h = Math.max(2, w.h); map[w.id] = { x: 0, y, w: 1, h }; halfOpen = { y, h }; } + } else { + if (halfOpen) { y = halfOpen.y + halfOpen.h; halfOpen = null; } + const h = w.type === "clock" ? Math.min(w.h, 2) : w.h; + map[w.id] = { x: 0, y, w: 2, h }; + y += h; + } + } + return map; + } + + function rectFor(p) { + const { colW, gap, rh } = metrics; + return { left: p.x * (colW + gap), top: p.y * (rh + gap), width: p.w * colW + (p.w - 1) * gap, height: p.h * rh + (p.h - 1) * gap }; + } + + function placeEl(el, p) { + const r = rectFor(p); + Object.assign(el.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` }); + } + + function updateGridHeight(pos) { + const rows = Object.values(pos).reduce((m, p) => Math.max(m, p.y + p.h), 0) + (editMode ? 4 : 0); + gridEl.style.height = `${Math.max(rows * (metrics.rh + metrics.gap) - metrics.gap, 0)}px`; + const cw = metrics.colW + metrics.gap, ch = metrics.rh + metrics.gap; + const svg = ``; + gridEl.style.setProperty("--cell-bg", `url("data:image/svg+xml,${encodeURIComponent(svg)}")`); + gridEl.style.backgroundSize = `${cw}px ${ch}px`; + } + + /** Side menu with the dashboard's tabs (a bottom tab bar on phones). */ + function renderNav() { + const show = current.nav.show || (editMode && current.tabs.length > 1); + document.body.classList.toggle("has-tabs", show); + navEl.hidden = !show; + if (!show) return; + const active = tab().id; + navEl.innerHTML = current.tabs.map((t) => ``).join("") + + (editMode ? `` : ""); + } + + function setTab(id) { + activeTabId = id; + lsSet(`ds.tab.${current.id}`, id); + selectedId = null; + renderGrid(); + if (editMode) renderInspector(); + stageEl.scrollTop = 0; + } + + function addTab() { + const t = newTab(tr("Tab {0}", current.tabs.length + 1), "tile"); + mutate(() => { current.tabs.push(t); current.nav.show = true; }, null, { rerender: false }); + setTab(t.id); + } + + async function tabOp(op, id) { + const i = current.tabs.findIndex((t) => t.id === id); + if (op === "add") return addTab(); + if (op === "up" || op === "down") { + const j = op === "up" ? i - 1 : i + 1; + if (j < 0 || j >= current.tabs.length) return; + mutate(() => { [current.tabs[i], current.tabs[j]] = [current.tabs[j], current.tabs[i]]; }); + renderInspector(); + } else if (op === "del") { + if (current.tabs.length === 1) { toast(tr("A dashboard needs at least one tab."), true); return; } + const t = current.tabs[i]; + if (t.widgets.length && !(await confirmDestructive(tr("Delete tab “{0}”?", t.name), tr("Its {0} widgets will be deleted too. You can undo this with ⌘Z.", t.widgets.length), tr("Delete")))) return; + mutate(() => { current.tabs.splice(i, 1); }, null, { rerender: false }); + setTab(current.tabs[Math.max(0, i - 1)].id); + } + } + + function renderGrid() { + if (!current) return; + renderNav(); + applyTheme(); + computeMetrics(); + const pos = positions(); + gridEl.innerHTML = ""; + for (const w of tab().widgets) { + const el = document.createElement("div"); + el.className = "w"; + el.dataset.id = w.id; + gridEl.appendChild(el); + placeEl(el, pos[w.id]); + renderWidget(w, el); + } + updateGridHeight(pos); + } + + async function api(path) { + const r = await fetch(new URL(path, BASE)); + 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] ||= {}) }); + + function renderWidget(w, el = gridEl.querySelector(`.w[data-id="${w.id}"]`)) { + if (!el) return; + const def = Widgets[w.type]; + if (!def) { el.innerHTML = `
${tr("Unknown widget “{0}”.", esc(w.type))}
`; return; } + const ctx = ctxFor(w); + const st = w.style || {}; + const lit = def.lit?.(w.cfg, ctx) || false; + el.classList.toggle("selected", editMode && w.id === selectedId); + el.classList.toggle("transparent", !!st.transparent); + el.classList.toggle("lit-lamp", lit === "lamp"); + el.classList.toggle("lit-on", lit === "on"); + const lamp = lit === "lamp" ? def.lamp?.(w.cfg, ctx) : null; + for (const [prop, val] of [["--lamp", lamp], ["--w-bg", st.bg], ["--w-color", st.color], ["--w-accent", st.accent]]) { + if (val) el.style.setProperty(prop, val); else el.style.removeProperty(prop); + } + let html; + try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `
${tr("This widget couldn’t be drawn.")}
`; } + el.innerHTML = `
${html}
` + + (editMode ? `
` : ""); + 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); + } + + function entitiesOf(w) { + const def = Widgets[w.type]; + if (!def) return []; + if (def.entities) return def.entities(w.cfg, ha); + return w.cfg.entity ? [w.cfg.entity] : []; + } + + let interacting = false; + function onStateChange(entityId) { + if (!current) return; + if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; } + for (const w of tab().widgets) { + if (!entitiesOf(w).includes(entityId)) continue; + const el = gridEl.querySelector(`.w[data-id="${w.id}"]`); + if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag + const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null; + renderWidget(w, el); + if (focused) el.querySelector(`[data-act="${focused}"]`)?.focus(); + } + } + + function renderDashSelect() { + $("#dash-select").innerHTML = store.dashboards.map((d) => ``).join(""); + } + + function renderPalette() { + $("#palette-list").innerHTML = Object.entries(Widgets) + .map(([type, d]) => ``).join(""); + } + + function renderAll() { + document.body.classList.toggle("editing", editMode); + $("#btn-edit").textContent = editMode ? tr("Done") : tr("Edit"); + renderDashSelect(); + renderGrid(); + if (editMode) renderInspector(); + } + + // ------------------------------------------------------------ inspector + function entityOptions(domains) { + return Object.values(ha.states) + .filter((s) => !domains || domains.includes(domainOf(s.entity_id))) + .sort((a, b) => a.entity_id.localeCompare(b.entity_id)) + .map((s) => ``).join(""); + } + const listId = (domains) => `dl-${(domains || ["all"]).join("-")}`; + + function fieldHTML(f, value, path) { + f = { ...f, l: tr(f.l), ph: f.ph ? tr(f.ph) : f.ph }; + const id = `f-${path}-${f.k}`; + const data = `data-path="${path}" data-k="${f.k}"`; + const label = ``; + switch (f.t) { + case "check": + return `
`; + case "seg": + return `
${esc(f.l)}
+ ${f.o.map((o) => ``).join("")}
`; + case "select": + return `
${label}
`; + case "number": + return `
${label}
`; + case "textarea": + case "json": + return `
${label}
`; + case "color": + return `
${label}
+ +
`; + case "icon": + return `
${label}
+
`; + case "entity": { + const s = ha.states[value]; + return `
${label} + ${s ? `
${esc(friendly(s))} · ${esc(fmtState(s))}
` : value ? `
${tr("No entity with this ID")}
` : ""}
`; + } + case "entities": { + const lid = listId(f.domains); + const list = Array.isArray(value) ? value : []; + return `
${esc(f.l)} + ${list.map((e, i) => `
`).join("")} +
`; + } + case "albums": + return `
${esc(f.l)}
${tr("Loading albums…")}
`; + default: + return `
${label}
`; + } + } + + function datalists() { + const groups = new Set(["all"]); + for (const d of Object.values(Widgets)) for (const f of d.fields) if (f.domains) groups.add(f.domains.join("-")); + return [...groups].map((g) => `${entityOptions(g === "all" ? null : g.split("-"))}`).join("") + + `${Icons.names.map((n) => ``; + } + + const STYLE_FIELDS = [ + { k: "transparent", t: "check", l: "No background" }, + { k: "bg", t: "color", l: "Background", ph: "Theme" }, + { k: "color", t: "color", l: "Text color", ph: "Theme" }, + { k: "accent", t: "color", l: "Accent color", ph: "Theme" }, + ]; + const COLOR_FIELDS = [ + { k: "bg", t: "color", l: "Background (color or CSS gradient)" }, + { k: "tile", t: "color", l: "Tile color" }, + { k: "text", t: "color", l: "Text color" }, + { k: "text2", t: "color", l: "Secondary text color" }, + { k: "accent", t: "color", l: "Accent color" }, + ]; + const GRID_FIELDS = [ + { k: "cols", t: "number", l: "Columns" }, + { k: "rowHeight", t: "number", l: "Row height (px)" }, + { k: "gap", t: "number", l: "Spacing (px)" }, + { k: "maxWidth", t: "number", l: "Maximum width (px)", ph: "0 = full width" }, + ]; + + function renderInspector() { + const el = $("#inspector"); + const w = tab().widgets.find((x) => x.id === selectedId); + 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("")} +
${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") : ""} +
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}
+
`; + fillAlbums(el); + return; + } + const t = current.theme, k = current.kiosk; + el.innerHTML = `
${tr("Dashboard")}
+ ${fieldHTML({ k: "name", t: "text", l: "Name" }, current.name, "dash")} + ${Icons.names.map((n) => ` +
${tr("Tabs")}
+ ${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")} +
${current.tabs.map((t, i) => `
+ + + + + + +
`).join("")}
+ +
+
${tr("Appearance")}
+
${Object.entries(PRESETS).map(([n, p]) => { + const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg; + const fg = p.swatch ? "#6e6e73" : p.text; + return ``; + }).join("")}
+

${tr("System follows your device’s light or dark appearance.")}

+
+ ${fieldHTML({ k: "surface", t: "seg", l: "Tile style", o: ["cards", "outline", "plain"] }, t.surface, "theme")} + ${fieldHTML({ k: "radius", t: "number", l: "Corner radius (px)" }, t.radius, "theme")} +
+
${tr("Colors")}${t.preset !== "Custom" ? ` · ${tr("editing switches to Custom")}` : ""}
+ ${COLOR_FIELDS.map((f) => fieldHTML(f, t.preset === "System" ? "" : t[f.k], "theme")).join("")} + ${fieldHTML({ k: "bgImage", t: "text", l: "Background image URL", ph: "/local/wallpaper.jpg" }, t.bgImage, "theme")} + ${fieldHTML({ k: "blur", t: "number", l: "Blur behind tiles (px)", ph: "For photo backgrounds" }, t.blur, "theme")} +
+
${tr("Grid")}
+
${GRID_FIELDS.slice(0, 2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
+
${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
+
${tr("Kiosk")}
+ ${fieldHTML({ k: "saver", t: "number", l: "Screen saver after (minutes)", ph: "0 = off" }, k.saver, "kiosk")} + ${fieldHTML({ k: "exitCorner", t: "select", l: "Exit corner (tap it 5 times to leave)", o: ["top-right", "top-left", "bottom-right", "bottom-left"] }, k.exitCorner, "kiosk")} + ${fieldHTML({ k: "hideCursor", t: "check", l: "Hide the pointer when idle" }, k.hideCursor, "kiosk")} +
+

${tr("On a wall tablet, open kiosk in a new tab to hide the Home Assistant sidebar too.")}

+
+
${tr("Language")}
+ ${fieldHTML({ k: "lang", t: "select", l: "Language on this device", o: Object.keys(window.I18n.languages), labels: Object.fromEntries(Object.entries(window.I18n.languages).map(([c, n]) => [c, c === "auto" ? tr(n) : n])) }, window.I18n.preference(), "device")} +
+

${tr("Select a widget to edit it.")}

`; + } + + /** Album pickers load their list from the server after the inspector is drawn. */ + async function fillAlbums(root) { + for (const box of root.querySelectorAll(".albums")) { + const selected = new Set(JSON.parse(box.dataset.selected || "[]")); + try { + const albums = await api("api/immich/albums"); + if (!box.isConnected) return; + box.innerHTML = albums.length + ? albums.map((a) => ``).join("") + : `
${tr("No albums found in Immich.")}
`; + } catch (e) { + box.innerHTML = `
${esc(e.message)}
`; + } + } + } + + function applyField(t, v, isChange) { + const path = t.dataset.path, k = t.dataset.k, idx = t.dataset.idx; + if (path === "tab") { + const tb = current.tabs.find((x) => x.id === t.dataset.tab); + mutate(() => { tb[k] = v; }, `tab|${tb.id}|${k}`, { rerender: false }); + renderNav(); + if (isChange && k === "icon") renderInspector(); + return; + } + if (path === "nav") { + mutate(() => { current.nav[k] = v; }); + return; + } + if (path === "move") { + const w = tab().widgets.find((x) => x.id === selectedId), target = current.tabs.find((x) => x.id === v); + if (!w || !target || target === tab()) return; + mutate(() => { + tab().widgets = tab().widgets.filter((x) => x !== w); + Object.assign(w, findSpot(w.w, w.h, target.widgets)); + target.widgets.push(w); + }); + select(null); + toast(tr("Moved to “{0}”.", target.name)); + return; + } + if (path === "device") { // per-device language: stored locally, applied by reloading + lsSet("ds.lang", v); + location.reload(); + return; + } + const w = tab().widgets.find((x) => x.id === selectedId); + const key = `${selectedId}|${path}|${k}|${idx ?? ""}`; + + if (idx === "new") { + if (!isChange || !v) return; + mutate(() => { (w.cfg[k] ||= []).push(v); }, null); + renderInspector(); + $(`#inspector [data-k="${k}"][data-idx="new"]`)?.focus(); + return; + } + if (t.dataset.json && v) { + try { JSON.parse(v); t.classList.remove("invalid"); } catch { t.classList.add("invalid"); return; } + } + + mutate(() => { + if (path === "cfg") { + if (idx != null) w.cfg[k][Number(idx)] = v; + else w.cfg[k] = v; + } else if (path === "style") { (w.style ||= {})[k] = v; } + else if (path === "pos") { + const n = Math.max(k === "w" || k === "h" ? 1 : 0, Math.round(Number(v) || 0)); + w[k] = k === "x" || k === "w" ? Math.min(n, current.grid.cols - (k === "x" ? w.w : w.x)) : n; + resolveCollisions(w); + } else if (path === "theme") { + const th = current.theme; + if (COLOR_KEYS.includes(k) && th.preset !== "Custom") { + if (th.preset === "System") Object.assign(th, PRESETS[prefersDark() ? "Dark" : "Light"]); + th.preset = "Custom"; + } + th[k] = v; + } else if (path === "grid") { current.grid[k] = v === "" ? 0 : v; } + else if (path === "kiosk") { current.kiosk[k] = v; } + else if (path === "dash") { current.name = v; renderDashSelect(); } + }, key, { rerender: ["pos", "theme", "grid"].includes(path) }); + + if (path === "cfg" || path === "style") renderWidget(w); + 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(); + } + + function onInspectorInput(e) { + const t = e.target; + const albums = t.closest?.(".albums"); + if (albums) { + if (e.type !== "change") return; + const ids = [...albums.querySelectorAll("input:checked")].map((i) => i.value); + applyField({ dataset: { path: "cfg", k: "albums" }, tagName: "DIV", classList: { add() {}, remove() {} } }, ids, false); + return; + } + if (t.dataset.sync) { // color swatch -> text field + const txt = document.getElementById(t.dataset.sync); + txt.value = t.value; + applyField(txt, t.value, e.type === "change"); + return; + } + if (!t.dataset.path) return; + let v = t.type === "checkbox" ? t.checked : t.value; + if (t.dataset.num) v = v === "" ? "" : Number(v); + applyField(t, v, e.type === "change"); + } + + // ------------------------------------------------------------ layout ops + const overlap = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + + // Push anything overlapping `moved` downward, cascading. + function resolveCollisions(moved) { + const queue = [moved]; + let guard = 0; + while (queue.length && guard++ < 2000) { + const a = queue.shift(); + for (const b of tab().widgets) { + if (b !== a && overlap(a, b)) { b.y = a.y + a.h; queue.push(b); } + } + } + } + + function findSpot(w, h, widgets = tab().widgets) { + const cols = current.grid.cols; + for (let y = 0; y < 500; y++) + for (let x = 0; x + w <= cols; x++) + if (!widgets.some((o) => overlap({ x, y, w, h }, o))) return { x, y }; + return { x: 0, y: 0 }; + } + + function addWidget(type) { + const wd = makeWidget(type); + wd.w = Math.min(wd.w, current.grid.cols); + Object.assign(wd, findSpot(wd.w, wd.h)); + mutate(() => tab().widgets.push(wd)); + select(wd.id); + gridEl.querySelector(`.w[data-id="${wd.id}"]`)?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + + function duplicate(id) { + const src = tab().widgets.find((w) => w.id === id); + if (!src) return; + const copy = { ...clone(src), id: uid() }; + Object.assign(copy, findSpot(copy.w, copy.h)); + mutate(() => tab().widgets.push(copy)); + select(copy.id); + } + + function remove(id) { + mutate(() => { tab().widgets = tab().widgets.filter((w) => w.id !== id); }); + delete caches[id]; + select(null); + toast(tr("Widget deleted. Press ⌘Z to undo.")); + } + + function select(id) { + selectedId = id; + gridEl.querySelectorAll(".w").forEach((el) => el.classList.toggle("selected", el.dataset.id === id)); + renderInspector(); + } + + // ------------------------------------------------------------ drag / resize + let drag = null; + + gridEl.addEventListener("pointerdown", (e) => { + if (!editMode || e.button !== 0) return; + const el = e.target.closest(".w"); + if (!el) { select(null); return; } + if (e.target.closest("[data-tool]")) return; + const w = tab().widgets.find((x) => x.id === el.dataset.id); + if (selectedId !== w.id) select(w.id); + const mode = e.target.closest(".w-resize") ? "resize" : "move"; + const ph = document.createElement("div"); + ph.className = "placeholder"; + gridEl.appendChild(ph); + placeEl(ph, w); + drag = { w, el, ph, mode, sx: e.clientX, sy: e.clientY, orig: { x: w.x, y: w.y, w: w.w, h: w.h }, next: { x: w.x, y: w.y, w: w.w, h: w.h }, r0: rectFor(w) }; + el.classList.add("dragging"); + el.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + + gridEl.addEventListener("pointermove", (e) => { + if (!drag) return; + const { colW, gap, rh, cols } = metrics; + const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy; + const o = drag.orig, n = drag.next; + if (drag.mode === "move") { + drag.el.style.left = `${drag.r0.left + dx}px`; + drag.el.style.top = `${drag.r0.top + dy}px`; + n.x = Math.max(0, Math.min(cols - o.w, o.x + Math.round(dx / (colW + gap)))); + n.y = Math.max(0, o.y + Math.round(dy / (rh + gap))); + } else { + drag.el.style.width = `${Math.max(colW, drag.r0.width + dx)}px`; + drag.el.style.height = `${Math.max(rh, drag.r0.height + dy)}px`; + n.w = Math.max(1, Math.min(cols - o.x, o.w + Math.round(dx / (colW + gap)))); + n.h = Math.max(1, o.h + Math.round(dy / (rh + gap))); + } + placeEl(drag.ph, n); + const pos = positions(); pos[drag.w.id] = n; + updateGridHeight(pos); + }); + + function endDrag() { + if (!drag) return; + const { w, ph, el, orig, next } = drag; + ph.remove(); + el.classList.remove("dragging"); + drag = null; + if (orig.x === next.x && orig.y === next.y && orig.w === next.w && orig.h === next.h) { placeEl(el, w); return; } + mutate(() => { Object.assign(w, next); resolveCollisions(w); }); + select(w.id); + } + gridEl.addEventListener("pointerup", endDrag); + gridEl.addEventListener("pointercancel", endDrag); + + // ------------------------------------------------------------ actions (view mode) + function defaultAction(eid) { + const d = domainOf(eid), s = ha.states[eid]; + switch (d) { + case "scene": case "script": return ha.callService(d, "turn_on", {}, eid); + case "button": case "input_button": return ha.callService(d, "press", {}, eid); + case "lock": return ha.callService("lock", s?.state === "locked" ? "unlock" : "lock", {}, eid); + case "cover": return ha.callService("cover", "toggle", {}, eid); + case "media_player": return ha.callService("media_player", "media_play_pause", {}, eid); + case "climate": return ha.callService("climate", s?.state === "off" ? "turn_on" : "turn_off", {}, eid); + default: return ha.callService("homeassistant", "toggle", {}, eid); + } + } + + gridEl.addEventListener("click", (e) => { + if (editMode) { + const tool = e.target.closest("[data-tool]"); + if (tool) { + const id = tool.closest(".w").dataset.id; + tool.dataset.tool === "dup" ? duplicate(id) : remove(id); + } + return; + } + const a = e.target.closest("[data-act]"); + if (!a || a.tagName === "INPUT") return; + const eid = a.dataset.e, act = a.dataset.act; + if (act === "toggle") defaultAction(eid); + else if (act === "more") moreInfo(eid); + else if (act === "temp") { + const s = ha.states[eid]; + const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10; + 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 === "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; + 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); } + else if (c.entity) defaultAction(c.entity); + else { toast(tr("This button has no action yet. Set one in the inspector."), true); return; } + a.animate?.([{ transform: "scale(.96)" }, { transform: "scale(1)" }], 160); + } + }); + + gridEl.addEventListener("input", (e) => { + const t = e.target; + if (!t.dataset.act) return; + interacting = true; + t.style.setProperty("--pct", `${t.value}%`); + }); + gridEl.addEventListener("change", (e) => { + const t = e.target, eid = t.dataset.e; + interacting = false; + if (t.dataset.act === "brightness") ha.callService("light", "turn_on", { brightness_pct: Number(t.value) }, eid); + if (t.dataset.act === "volume") ha.callService("media_player", "volume_set", { volume_level: Number(t.value) / 100 }, eid); + }); + + // ------------------------------------------------------------ dialogs + let modalClose = null; + function openModal(html, onClick) { + const card = $("#modal .modal-card"); + card.innerHTML = html; + $("#modal").hidden = false; + const prevFocus = document.activeElement; + return new Promise((resolve) => { + modalClose = (result) => { + $("#modal").hidden = true; + modalClose = null; + prevFocus?.focus?.(); + resolve(result); + }; + card.onclick = (e) => onClick?.(e, modalClose); + (card.querySelector("[autofocus]") || card.querySelector("button"))?.focus(); + }); + } + $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") modalClose?.(null); }); + + /** A short sheet with a text field. Resolves to the text, or null when cancelled. */ + function askText(title, value, confirmLabel) { + return openModal(`

${esc(title)}

+
+
`, + (e, close) => { if (e.target.dataset.x === "cancel") close(null); }); + } + document.addEventListener("submit", (e) => { + if (e.target.id !== "ask-form") return; + e.preventDefault(); + const v = $("#ask-input").value.trim(); + if (v) modalClose?.(v); + }); + + function confirmDestructive(title, message, label) { + return openModal(`

${esc(title)}

${esc(message)}

+ `, + (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok"); }); + } + + function moreInfo(eid) { + const s = ha.states[eid]; + if (!s) return; + const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid)); + const actionLabel = domainOf(eid) === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(domainOf(eid)) ? tr("Run") : isOn(s) ? tr("Turn off") : tr("Turn on"); + openModal(`

${esc(friendly(s))}

${esc(eid)}
+
${esc(fmtState(s))}
+
${esc(tr("Changed {0}", relTime(s.last_changed)))}
+ ${Object.entries(s.attributes).map(([k, v]) => ``).join("")}
${esc(cap(k))}${esc(typeof v === "object" ? JSON.stringify(v) : v)}
+ `, + (e, close) => { + const x = e.target.closest("[data-x]")?.dataset.x; + if (x === "toggle") defaultAction(eid); + if (x) close(); + }); + } + + function toast(msg, err) { + const el = document.createElement("div"); + el.className = "toast" + (err ? " err" : ""); + el.textContent = msg; + $("#toasts").appendChild(el); + setTimeout(() => el.remove(), err ? 6000 : 3500); + } + + // ------------------------------------------------------------ kiosk + // No visible controls. Tapping one screen corner 5 times quickly opens a + // short-lived exit prompt; Escape does the same on a keyboard. + const CORNER_PX = 90, CORNER_TAPS = 5, CORNER_WINDOW_MS = 2500, PROMPT_MS = 8000; + let kiosk = false, swallowClick = false, wakeLock = null, idleTimer = 0, saverTimer = 0; + let cornerTaps = [], promptOpen = false; + + async function requestWakeLock() { + try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; } + } + + const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " "); + + function enterKiosk({ fullscreen = true } = {}) { + if (editMode) setEdit(false); + kiosk = true; + document.body.classList.add("kiosk"); + if (fullscreen) toggleFullscreen(true); + requestWakeLock(); + history.replaceState(null, "", `#kiosk&view=${current.id}`); + renderGrid(); + poke(); + toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS)); + } + + function exitKiosk() { + kiosk = false; + document.body.classList.remove("kiosk", "idle"); + $("#saver").hidden = true; + clearTimeout(idleTimer); clearTimeout(saverTimer); + wakeLock?.release?.(); wakeLock = null; + if (document.fullscreenElement) document.exitFullscreen?.(); + history.replaceState(null, "", location.pathname); + renderGrid(); + } + + function toggleFullscreen(quiet) { + if (document.fullscreenElement) { document.exitFullscreen?.(); return; } + const p = document.documentElement.requestFullscreen?.(); + if (!p) { if (!quiet) toast(tr("Full screen isn’t available in this browser."), true); return; } + p.catch(() => { if (!quiet) toast(tr("Home Assistant’s frame blocks full screen. Use “New tab” in the kiosk settings instead."), true); }); + } + + /** The exit prompt closes itself if nobody answers, so the screen goes back to the dashboard. */ + async function kioskPrompt() { + if (promptOpen || modalClose) return; + promptOpen = true; + const timer = setTimeout(() => modalClose?.(null), PROMPT_MS); + const choice = await openModal(`

${tr("Leave kiosk mode?")}

${tr("This closes by itself in a few seconds.")}

+ `, + (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); }); + clearTimeout(timer); + promptOpen = false; + if (choice === "exit") exitKiosk(); + else if (choice === "full") toggleFullscreen(false); + } + + function inExitCorner(e) { + const c = current.kiosk.exitCorner || "top-right"; + const x = c.endsWith("left") ? e.clientX : window.innerWidth - e.clientX; + const y = c.startsWith("top") ? e.clientY : window.innerHeight - e.clientY; + return x < CORNER_PX && y < CORNER_PX; + } + + /** Any activity: wake from the screen saver, count corner taps, reset idle timers. */ + function poke(e) { + if (!kiosk) return; + if (!$("#saver").hidden) { + $("#saver").hidden = true; + if (e?.type === "pointerdown") swallowClick = true; // the waking tap shouldn't also press a tile + } else if (e?.type === "pointerdown" && !modalClose && inExitCorner(e)) { + swallowClick = true; // corner taps never press whatever widget sits there + const now = Date.now(); + cornerTaps = [...cornerTaps.filter((t) => now - t < CORNER_WINDOW_MS), now]; + if (cornerTaps.length >= CORNER_TAPS) { cornerTaps = []; kioskPrompt(); } + } + document.body.classList.remove("idle"); + clearTimeout(idleTimer); + if (current.kiosk.hideCursor) idleTimer = setTimeout(() => document.body.classList.add("idle"), 3000); + clearTimeout(saverTimer); + const mins = Number(current.kiosk.saver) || 0; + if (mins > 0) saverTimer = setTimeout(showSaver, mins * 60000); + } + + function showSaver() { + if (!kiosk || modalClose) return; + updateSaver(); + $("#saver").hidden = false; + } + function updateSaver() { + const d = new Date(); + $("#saver .time").textContent = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); + $("#saver .date").textContent = d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" }); + } + + for (const ev of ["pointerdown", "pointermove", "keydown", "wheel"]) document.addEventListener(ev, poke, { capture: true, passive: true }); + document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true); + document.addEventListener("visibilitychange", () => { if (kiosk && document.visibilityState === "visible") requestWakeLock(); }); + + // ------------------------------------------------------------ chrome wiring + function setCurrent(id) { + current = store.dashboards.find((d) => d.id === id) || store.dashboards[0]; + lsSet("ds.current", current.id); + activeTabId = lsGet(`ds.tab.${current.id}`); + undoStack.length = redoStack.length = 0; + selectedId = null; + updateUndoButtons(); + renderAll(); + } + + function setEdit(on) { + editMode = on; + lsSet("ds.edit", on ? "1" : "0"); + if (!on) selectedId = null; + renderAll(); + } + + function setMenu(open) { + $("#menu").hidden = !open; + $("#btn-menu").setAttribute("aria-expanded", String(open)); + if (open) $("#menu button")?.focus(); + } + + $("#btn-edit").onclick = () => setEdit(!editMode); + $("#btn-kiosk").onclick = () => enterKiosk(); + $("#btn-undo").onclick = undo; + $("#btn-redo").onclick = redo; + $("#dash-select").onchange = (e) => setCurrent(e.target.value); + $("#btn-new").onclick = async () => { + const name = await askText(tr("New dashboard"), tr("New dashboard"), tr("Create")); + if (!name) return; + const d = newDashboard(name); + store.dashboards.push(d); + scheduleSave(); + setCurrent(d.id); + setEdit(true); + }; + $("#btn-menu").onclick = (e) => { e.stopPropagation(); setMenu($("#menu").hidden); }; + document.addEventListener("click", (e) => { if (!e.target.closest("#menu")) setMenu(false); }); + + $("#menu").addEventListener("click", async (e) => { + const act = e.target.closest("[data-menu]")?.dataset.menu; + if (!act) return; + setMenu(false); + 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 === "starter") { + if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } + const d = starterDashboard(); + store.dashboards.push(d); scheduleSave(); setCurrent(d.id); + toast(tr("Created “{0}” from your devices.", d.name)); + } else if (act === "duplicate") { + const d = clone(current); d.id = uid(); d.name += ` ${tr("copy")}`; + d.tabs.forEach((t) => { t.id = uid(); t.widgets.forEach((w) => (w.id = uid())); }); + store.dashboards.push(d); scheduleSave(); setCurrent(d.id); + } else if (act === "export") { + const blob = new Blob([JSON.stringify(current, null, 2)], { type: "application/json" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${current.name.replace(/[^\w-]+/g, "_")}.dashboard.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + } else if (act === "import") { + $("#import-file").click(); + } else if (act === "delete") { + if (store.dashboards.length === 1) { toast(tr("This is your only dashboard. Create another one before deleting it."), true); return; } + const ok = await confirmDestructive(tr("Delete “{0}”?", current.name), tr("The dashboard and its widgets will be removed. This can’t be undone."), tr("Delete")); + if (!ok) return; + store.dashboards = store.dashboards.filter((d) => d.id !== current.id); + scheduleSave(); setCurrent(store.dashboards[0].id); + } + }); + + $("#import-file").onchange = async (e) => { + const file = e.target.files[0]; + e.target.value = ""; + if (!file) return; + try { + const d = migrate(JSON.parse(await file.text())); + if (!d.tabs.every((t) => Array.isArray(t.widgets))) throw new Error(tr("the file has no widgets list")); + d.id = uid(); + d.tabs.forEach((t) => { t.id = uid(); t.widgets.forEach((w) => (w.id = uid())); }); + d.name = d.name || file.name; + store.dashboards.push(d); scheduleSave(); setCurrent(d.id); + toast(tr("Imported “{0}”.", d.name)); + } catch (err) { toast(tr("Couldn’t import: {0}.", err.message), true); } + }; + + navEl.addEventListener("click", (e) => { + const op = e.target.closest("[data-tabop]"); + if (op) { tabOp(op.dataset.tabop); return; } + const b = e.target.closest("[data-tab]"); + if (b) setTab(b.dataset.tab); + }); + + $("#palette-list").addEventListener("click", (e) => { + const b = e.target.closest("[data-add]"); + if (b) addWidget(b.dataset.add); + }); + + const insp = $("#inspector"); + insp.addEventListener("input", onInspectorInput); + insp.addEventListener("change", onInspectorInput); + insp.addEventListener("click", (e) => { + const seg = e.target.closest(".seg button[data-path]"); + if (seg) { applyField(seg, seg.dataset.v, true); return; } + const p = e.target.closest("[data-preset]"); + if (p) { + mutate(() => { + const keep = { surface: current.theme.surface, radius: current.theme.radius, bgImage: current.theme.bgImage }; + current.theme = { ...DEFAULT_THEME, ...PRESETS[p.dataset.preset], ...keep, preset: p.dataset.preset }; + delete current.theme.swatch; + }); + renderInspector(); return; + } + const del = e.target.closest("[data-ent-del]"); + if (del) { + const w = tab().widgets.find((x) => x.id === selectedId); + mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false }); + renderWidget(w); renderInspector(); return; + } + const op = e.target.closest("[data-tabop]"); + if (op) { tabOp(op.dataset.tabop, op.dataset.tabId); return; } + const k = e.target.closest("[data-kiosk]"); + if (k) { + if (k.dataset.kiosk === "here") enterKiosk(); + else window.open(new URL(`#kiosk&view=${current.id}`, BASE).href, "_blank", "noopener"); + return; + } + const tool = e.target.closest("[data-tool]"); + if (tool && selectedId) tool.dataset.tool === "dup" ? duplicate(selectedId) : remove(selectedId); + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + if (modalClose) { modalClose(null); return; } + if (!$("#menu").hidden) { setMenu(false); $("#btn-menu").focus(); return; } + if (kiosk) { kioskPrompt(); return; } + } + if (!editMode || modalClose) return; + const inField = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName); + const mod = e.ctrlKey || e.metaKey; + if (mod && e.key.toLowerCase() === "z" && !inField) { e.preventDefault(); e.shiftKey ? redo() : undo(); return; } + if (mod && e.key.toLowerCase() === "y" && !inField) { e.preventDefault(); redo(); return; } + if (inField || !selectedId) return; + const w = tab().widgets.find((x) => x.id === selectedId); + if (!w) return; + if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); remove(w.id); } + else if (mod && e.key.toLowerCase() === "d") { e.preventDefault(); duplicate(w.id); } + else if (e.key === "Escape") select(null); + else if (e.key.startsWith("Arrow")) { + e.preventDefault(); + const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; + mutate(() => { + if (e.shiftKey) { w.w = Math.max(1, Math.min(current.grid.cols - w.x, w.w + d[0])); w.h = Math.max(1, w.h + d[1]); } + else { w.x = Math.max(0, Math.min(current.grid.cols - w.w, w.x + d[0])); w.y = Math.max(0, w.y + d[1]); } + resolveCollisions(w); + }, `kbd-${w.id}`); + select(w.id); + } + }); + + let resizeRaf = 0; + window.addEventListener("resize", () => { cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(renderGrid); }); + matchMedia("(prefers-color-scheme: dark)").addEventListener?.("change", () => renderGrid()); + + // clocks, screen saver and camera refresh + setInterval(() => { + gridEl.querySelectorAll("[data-clock]").forEach((el) => { el.innerHTML = clockHTML(el.dataset.h24 === "1", el.dataset.sec === "1", el.dataset.date === "1"); }); + if (!$("#saver").hidden) updateSaver(); + const now = Date.now(); + gridEl.querySelectorAll("img[data-cam]").forEach((img) => { + const every = Number(img.dataset.refresh) * 1000; + if (!img._last) img._last = now; + if (now - img._last >= every) { img._last = now; img.src = `api/camera/${encodeURIComponent(img.dataset.cam)}?t=${now}`; } + }); + }, 1000); + // keep "x min ago" labels and graph time windows fresh + setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000); + + // ------------------------------------------------------------ boot + function paintChromeIcons() { + $("#btn-new").innerHTML = Icons.svg("plus"); + $("#btn-undo").innerHTML = Icons.svg("undo"); + $("#btn-redo").innerHTML = Icons.svg("redo"); + $("#btn-menu").innerHTML = Icons.svg("more"); + $("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk")); + document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon))); + } + + ha.onStatus = (ok) => { + const c = $("#conn"); + c.classList.toggle("ok", ok); + c.textContent = ok ? tr("Connected") : tr("Offline"); + }; + ha.onChange = onStateChange; + + /** Translate the static page: elements marked data-i18n, plus every aria-label and title. */ + function translateStatic() { + document.querySelectorAll("[data-i18n]").forEach((el) => { el.textContent = tr(el.textContent.trim()); }); + for (const attr of ["aria-label", "title"]) document.querySelectorAll(`[${attr}]`).forEach((el) => el.setAttribute(attr, tr(el.getAttribute(attr)))); + } + + async function boot() { + translateStatic(); + paintChromeIcons(); + renderPalette(); + try { + const r = await fetch(new URL("api/dashboards", BASE)); + store = await r.json(); + store.dashboards = (store.dashboards || []).map(migrate); + } catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); } + + const firstReady = new Promise((res) => { + const prev = ha.onStatus; + ha.onStatus = (ok) => { prev(ok); if (ok) res(); }; + setTimeout(res, 4000); + }); + ha.connect(); + + if (!store.dashboards?.length) { + await firstReady; + store = { version: 1, dashboards: [starterDashboard()] }; + save(); + } + const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1]; + setCurrent(hashId || lsGet("ds.current")); + if (location.hash.includes("kiosk")) enterKiosk({ fullscreen: false }); + } + + boot(); +})(); diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js new file mode 100644 index 0000000..7f56d44 --- /dev/null +++ b/dashboard_studio/web/i18n.js @@ -0,0 +1,268 @@ +/* Translations. English text is the key; a missing translation falls back to English. + * Language: the device setting in Dashboard Studio, else Home Assistant's own language + * (same origin, so its localStorage is readable), else the browser's. */ +(function () { + const NL = { + // --- toolbar, menus, dialogs + "Dashboard": "Dashboard", + "New dashboard": "Nieuw dashboard", + "Undo": "Ongedaan maken", + "Redo": "Opnieuw", + "More actions": "Meer acties", + "Kiosk": "Kiosk", + "Kiosk mode": "Kioskmodus", + "Edit": "Bewerken", + "Done": "Gereed", + "Offline": "Offline", + "Connected": "Verbonden", + "Saving…": "Opslaan…", + "Saved": "Opgeslagen", + "Not saved": "Niet opgeslagen", + "Rename…": "Wijzig naam…", + "Duplicate": "Dupliceer", + "New dashboard from my devices": "Nieuw dashboard met mijn apparaten", + "Created “{0}” from your devices.": "“{0}” gemaakt met je apparaten.", + "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it.": "Dashboard Studio heeft geen toegang tot Home Assistant (SUPERVISOR_TOKEN ontbreekt). Herstart de app; als dit blijft, installeer de app opnieuw.", + "Export as JSON": "Exporteer als JSON", + "Import JSON…": "Importeer JSON…", + "Delete dashboard…": "Verwijder dashboard…", + "Widgets": "Widgets", + "Inspector": "Inspector", + "Add widget": "Widget toevoegen", + "Click to add. Drag to move, drag the corner to resize. Arrow keys move the selected widget, Shift+arrows resize, ⌘D duplicates, Delete removes.": + "Klik om toe te voegen. Sleep om te verplaatsen, sleep de hoek om het formaat te wijzigen. Pijltjestoetsen verplaatsen de geselecteerde widget, Shift+pijltjes wijzigen het formaat, ⌘D dupliceert, Delete verwijdert.", + "Cancel": "Annuleer", + "Create": "Maak aan", + "Rename": "Wijzig naam", + "Rename dashboard": "Wijzig naam van dashboard", + "Delete": "Verwijder", + "Close": "Sluit", + "Run": "Voer uit", + "Turn on": "Zet aan", + "Turn off": "Zet uit", + "Lock": "Vergrendel", + "Unlock": "Ontgrendel", + "Changed {0}": "Gewijzigd {0}", + "Delete “{0}”?": "“{0}” verwijderen?", + "The dashboard and its widgets will be removed. This can’t be undone.": "Het dashboard en de widgets worden verwijderd. Dit kan niet ongedaan worden gemaakt.", + "This is your only dashboard. Create another one before deleting it.": "Dit is je enige dashboard. Maak eerst een ander dashboard aan voordat je dit verwijdert.", + "Imported “{0}”.": "“{0}” geïmporteerd.", + "Couldn’t import: {0}.": "Importeren mislukt: {0}.", + "the file has no widgets list": "het bestand bevat geen widgets", + "Widget deleted. Press ⌘Z to undo.": "Widget verwijderd. Druk op ⌘Z om dit ongedaan te maken.", + "Couldn’t save. Your changes are kept here and will save on the next edit.": "Opslaan mislukt. Je wijzigingen blijven hier bewaard en worden bij de volgende wijziging opgeslagen.", + "Couldn’t load your dashboards. Check that the app is running.": "Je dashboards konden niet worden geladen. Controleer of de app draait.", + "Not connected to Home Assistant. Retrying…": "Niet verbonden met Home Assistant. Opnieuw proberen…", + "{0} failed: {1}": "{0} mislukt: {1}", + "The button’s action data isn’t valid JSON. Fix it in the inspector.": "De actiegegevens van de knop zijn geen geldige JSON. Pas dit aan in de inspector.", + "This button has no action yet. Set one in the inspector.": "Deze knop heeft nog geen actie. Stel er een in via de inspector.", + "Unknown widget “{0}”.": "Onbekende widget “{0}”.", + "This widget couldn’t be drawn.": "Deze widget kon niet worden getekend.", + "Home": "Thuis", + "Everything at a glance": "Alles in één oogopslag", + "Quick controls": "Snelle bediening", + "Temperature, last 24 hours": "Temperatuur, afgelopen 24 uur", + "copy": "kopie", + + // --- kiosk + "Kiosk mode is on. To leave, tap the {0} corner {1} times.": "Kioskmodus staat aan. Tik {1} keer op de hoek {0} om te stoppen.", + "top right": "rechtsboven", "top left": "linksboven", "bottom right": "rechtsonder", "bottom left": "linksonder", + "top-right": "Rechtsboven", "top-left": "Linksboven", "bottom-right": "Rechtsonder", "bottom-left": "Linksonder", + "Leave kiosk mode?": "Kioskmodus verlaten?", + "This closes by itself in a few seconds.": "Dit venster sluit vanzelf over een paar seconden.", + "Full screen": "Volledig scherm", + "Exit full screen": "Volledig scherm uit", + "Stay": "Blijf", + "Leave kiosk": "Verlaat kiosk", + "Full screen isn’t available in this browser.": "Volledig scherm is niet beschikbaar in deze browser.", + "Home Assistant’s frame blocks full screen. Use “New tab” in the kiosk settings instead.": "Home Assistant blokkeert volledig scherm in zijn venster. Gebruik “Nieuw tabblad” bij de kioskinstellingen.", + + // --- inspector + "Name": "Naam", + "Appearance": "Weergave", + "Theme": "Thema", + "System follows your device’s light or dark appearance.": "Systeem volgt de lichte of donkere weergave van je apparaat.", + "System": "Systeem", "Light": "Licht", "Dark": "Donker", "Nord": "Nord", "Dusk": "Schemer", "Forest": "Bos", "Custom": "Aangepast", + "Tile style": "Tegelstijl", + "Cards": "Kaarten", "Outline": "Omlijnd", "Plain": "Zonder kaart", + "Corner radius (px)": "Hoekafronding (px)", + "Colors": "Kleuren", + "editing switches to Custom": "aanpassen schakelt naar Aangepast", + "Background (color or CSS gradient)": "Achtergrond (kleur of CSS-verloop)", + "Tile color": "Tegelkleur", + "Text color": "Tekstkleur", + "Secondary text color": "Kleur van secundaire tekst", + "Accent color": "Accentkleur", + "Background image URL": "URL van achtergrondafbeelding", + "Blur behind tiles (px)": "Vervaging achter tegels (px)", + "For photo backgrounds": "Voor fotoachtergronden", + "Grid": "Raster", + "Columns": "Kolommen", + "Row height (px)": "Rijhoogte (px)", + "Spacing (px)": "Tussenruimte (px)", + "Maximum width (px)": "Maximale breedte (px)", + "0 = full width": "0 = volledige breedte", + "Screen saver after (minutes)": "Schermbeveiliging na (minuten)", + "0 = off": "0 = uit", + "Exit corner (tap it 5 times to leave)": "Uitgangshoek (tik 5 keer om te stoppen)", + "Hide the pointer when idle": "Verberg de aanwijzer bij inactiviteit", + "Start kiosk": "Start kiosk", + "New tab": "Nieuw tabblad", + "On a wall tablet, open kiosk in a new tab to hide the Home Assistant sidebar too.": "Open de kiosk op een wandtablet in een nieuw tabblad om ook de zijbalk van Home Assistant te verbergen.", + "Select a widget to edit it.": "Selecteer een widget om deze te bewerken.", + "Language": "Taal", + "Language on this device": "Taal op dit apparaat", + "Automatic": "Automatisch", + "Size and position": "Formaat en positie", + "Column": "Kolom", "Row": "Rij", "Width": "Breedte", "Height": "Hoogte", + "Style": "Stijl", + "No background": "Geen achtergrond", + "Background": "Achtergrond", + "Default": "Standaard", + "Add an entity…": "Voeg een entiteit toe…", + "Remove {0}": "Verwijder {0}", + "No entity with this ID": "Geen entiteit met deze ID", + "Loading albums…": "Albums laden…", + "No albums found in Immich.": "Geen albums gevonden in Immich.", + + // --- widget names and fields + "Heading": "Kop", "Entity tile": "Entiteitstegel", "Big value": "Grote waarde", "Gauge": "Meter", + "Thermostat": "Thermostaat", "History graph": "Geschiedenisgrafiek", "Clock": "Klok", "Weather": "Weer", + "Entity list": "Entiteitenlijst", "Button": "Knop", "Media player": "Mediaspeler", "Camera": "Camera", + "Car": "Auto", "Text": "Tekst", "Photos": "Foto’s", "Calendar": "Agenda", "Web page": "Webpagina", + "Subtitle": "Ondertitel", "Font size (px)": "Lettergrootte (px)", "Alignment": "Uitlijning", + "left": "Links", "center": "Midden", "right": "Rechts", + "Welcome home": "Welkom thuis", + "Entity": "Entiteit", "Entity name": "Naam van entiteit", "Icon": "Pictogram", "Tap action": "Tikactie", + "auto": "Auto", "toggle": "Schakel", "more-info": "Info", "none": "Geen", + "Show when it last changed": "Toon wanneer het laatst gewijzigd is", + "Unit": "Eenheid", "From entity": "Van entiteit", "Decimals": "Decimalen", + "Warn above": "Waarschuwing boven", "Critical above": "Kritiek boven", + "Minimum": "Minimum", "Maximum": "Maximum", "Green from": "Groen vanaf", "Yellow from": "Geel vanaf", "Red from": "Rood vanaf", + "Step": "Stap", "Title": "Titel", "Sensors": "Sensoren", "Hours to show": "Aantal uren", + "Fill under the line": "Vul onder de lijn", + "24-hour time": "24-uursnotatie", "Show seconds": "Toon seconden", "Show date": "Toon datum", + "Devices": "Apparaten", "Entities": "Entiteiten", + "Label": "Label", "Target entity (optional)": "Doelentiteit (optioneel)", + "Action (e.g. scene.turn_on)": "Actie (bijv. scene.turn_on)", "Action data (JSON)": "Actiegegevens (JSON)", + "Refresh every (seconds)": "Vernieuw elke (seconden)", "Show name": "Toon naam", + "My car": "Mijn auto", "Car image URL (optional)": "URL van autoafbeelding (optioneel)", + "Drivetrain": "Aandrijving", "electric": "Elektrisch", "fuel": "Brandstof", + "Battery or fuel level (%)": "Accu- of brandstofniveau (%)", "Range": "Actieradius", + "Charging power": "Laadvermogen", "Charge limit (%)": "Laadlimiet (%)", "Doors": "Portieren", + "Climate": "Klimaat", "Location": "Locatie", "Odometer": "Kilometerstand", + "Text — use {{ sensor.x }} for live values": "Tekst — gebruik {{ sensor.x }} voor live waarden", + "Inside it’s {{ sensor.living_room_temperature }}.": "Binnen is het {{ sensor.living_room_temperature }}.", + "Source": "Bron", "links": "Links", "immich": "Immich", + "Image URLs, one per line": "Afbeeldings-URL’s, één per regel", + "Immich albums": "Immich-albums", + "Change photo every (seconds)": "Wissel foto elke (seconden)", + "Shuffle": "Willekeurige volgorde", + "Fit": "Passend", "cover": "Vullen", "contain": "Volledig", + "Show when the photo was taken": "Toon wanneer de foto is gemaakt", + "Description (for screen readers)": "Beschrijving (voor schermlezers)", + "Upcoming": "Binnenkort", "Calendars": "Agenda’s", "Days ahead": "Aantal dagen vooruit", + "Most events to show": "Maximaal aantal afspraken", "Show which calendar": "Toon welke agenda", + + // --- widget content + "Unavailable": "Niet beschikbaar", "Unknown": "Onbekend", + "On": "Aan", "Off": "Uit", "Open": "Open", "Closed": "Dicht", "Clear": "Vrij", "Motion": "Beweging", + "Occupied": "Bezet", "Dry": "Droog", "Wet": "Nat", "Smoke": "Rook", "Away": "Afwezig", + "Unplugged": "Niet aangesloten", "Plugged in": "Aangesloten", "Not charging": "Laadt niet", "Charging": "Laden", + "Scene": "Scène", "Running": "Actief", "Script": "Script", + "just now": "zojuist", "{0} min ago": "{0} min geleden", "{0} h ago": "{0} uur geleden", "{0} d ago": "{0} d geleden", + "Can’t find {0}.": "{0} niet gevonden.", + "Check the entity ID in the inspector.": "Controleer de entiteit-ID in de inspector.", + "Choose {0} in the inspector.": "Kies {0} in de inspector.", + "an entity": "een entiteit", "a light": "een lamp", "a thermostat": "een thermostaat", + "a weather entity": "een weerentiteit", "a media player": "een mediaspeler", "a camera": "een camera", + "Critical": "Kritiek", "High": "Hoog", + "Now": "Nu", "Lower to {0}°": "Verlaag naar {0}°", "Raise to {0}°": "Verhoog naar {0}°", "Mode": "Modus", + "Add one or more sensors in the inspector.": "Voeg in de inspector een of meer sensoren toe.", + "Loading history…": "Geschiedenis laden…", + "No numeric history for this period.": "Geen numerieke geschiedenis voor deze periode.", + "History for the last {0} hours": "Geschiedenis van de afgelopen {0} uur", + "−{0} h": "−{0} u", + "Humidity": "Luchtvochtigheid", "Wind": "Wind", + "Add entities in the inspector.": "Voeg entiteiten toe in de inspector.", + "Previous track": "Vorige track", "Play": "Afspelen", "Pause": "Pauzeer", "Next track": "Volgende track", "Volume": "Volume", + "{0} camera": "Camera {0}", + "Set the car’s level, range or lock entity in the inspector.": "Stel in de inspector het niveau, de actieradius of het slot van de auto in.", + "Parked at home": "Thuis geparkeerd", "Locked": "Vergrendeld", "Unlocked": "Ontgrendeld", + "Battery level": "Accuniveau", "Fuel level": "Brandstofniveau", "Charge limit {0}%": "Laadlimiet {0}%", + "Climate on": "Klimaat aan", "range": "actieradius", "Low": "Laag", + "Choose one or more Immich albums in the inspector.": "Kies in de inspector een of meer Immich-albums.", + "Add image URLs in the inspector.": "Voeg in de inspector afbeeldings-URL’s toe.", + "Photo slideshow": "Diavoorstelling", + "Loading photos…": "Foto’s laden…", + "Couldn’t load photos. {0}": "Foto’s laden mislukt. {0}", + "These albums have no photos yet.": "Deze albums bevatten nog geen foto’s.", + "Add one or more calendars in the inspector.": "Voeg in de inspector een of meer agenda’s toe.", + "Loading events…": "Afspraken laden…", + "Couldn’t load events. {0}": "Afspraken laden mislukt. {0}", + "Nothing planned for the next {0} days.": "Niets gepland voor de komende {0} dagen.", + "Embedded page": "Ingesloten pagina", "Add a URL in the inspector.": "Voeg in de inspector een URL toe.", + // tabs + "Overview": "Overzicht", "Add tab": "Tabblad toevoegen", "Tab {0}": "Tabblad {0}", "Tabs": "Tabbladen", "Tab": "Tabblad", + "A dashboard needs at least one tab.": "Een dashboard heeft minstens één tabblad nodig.", + "Delete tab “{0}”?": "Tabblad “{0}” verwijderen?", + "Its {0} widgets will be deleted too. You can undo this with ⌘Z.": "De {0} widgets erop worden ook verwijderd. Je kunt dit ongedaan maken met ⌘Z.", + "Show tabs in a side menu": "Toon tabbladen in een zijmenu", "Tab name": "Naam van tabblad", + "Move up": "Omhoog", "Move down": "Omlaag", "Moved to “{0}”.": "Verplaatst naar “{0}”.", + // waste + "Waste collection": "Afvalkalender", "Layout": "Indeling", "next": "Volgende", "list": "Lijst", + "Waste sensors (leave empty to find them automatically)": "Afvalsensoren (leeg laten om ze automatisch te vinden)", + "Most pickups to show": "Maximaal aantal ophaalmomenten", "Only pickups within (days)": "Alleen ophaalmomenten binnen (dagen)", + "No pickups in the next {0} days.": "Geen ophaalmomenten in de komende {0} dagen.", + "No Afvalwijzer sensors found. Install the Afvalwijzer integration, or pick the sensors in the inspector.": "Geen Afvalwijzer-sensoren gevonden. Installeer de Afvalwijzer-integratie of kies de sensoren in de inspector.", + "Next pickup": "Volgende ophaalmoment", "Put the bin out tonight": "Zet de container vanavond buiten", + "Day after tomorrow": "Overmorgen", "In {0} days": "Over {0} dagen", + "Organic": "GFT", "Paper": "Papier", "Residual waste": "Restafval", "Residual waste bags": "Restafvalzakken", + "Textile": "Textiel", "Bulky waste": "Grofvuil", "Hazardous waste": "KCA", "Nappies": "Luiers", "Glass": "Glas", + "Pruning waste": "Snoeiafval", "Garden waste": "Tuinafval", "Christmas trees": "Kerstbomen", "BEST bag": "BEST-tas", + "Hazardous waste van": "Milieubus", + "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", + }; + + // Home Assistant states, translated when shown as text. + const STATES_NL = { + on: "Aan", off: "Uit", unavailable: "Niet beschikbaar", unknown: "Onbekend", home: "Thuis", not_home: "Afwezig", + open: "Open", opening: "Gaat open", closed: "Dicht", closing: "Gaat dicht", locked: "Vergrendeld", unlocked: "Ontgrendeld", + locking: "Vergrendelen", unlocking: "Ontgrendelen", jammed: "Vastgelopen", playing: "Speelt", paused: "Gepauzeerd", + idle: "Inactief", standby: "Stand-by", buffering: "Bufferen", heat: "Verwarmen", cool: "Koelen", auto: "Automatisch", + heat_cool: "Verwarmen/koelen", dry: "Drogen", fan_only: "Alleen ventilator", cleaning: "Reinigen", docked: "Gedockt", + returning: "Keert terug", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon", + "clear-night": "Helder", cloudy: "Bewolkt", exceptional: "Uitzonderlijk", fog: "Mist", hail: "Hagel", lightning: "Onweer", + "lightning-rainy": "Onweer en regen", partlycloudy: "Halfbewolkt", pouring: "Stortregen", rainy: "Regen", snowy: "Sneeuw", + "snowy-rainy": "Natte sneeuw", sunny: "Zonnig", windy: "Winderig", "windy-variant": "Winderig", armed_away: "Ingeschakeld (afwezig)", + armed_home: "Ingeschakeld (thuis)", armed_night: "Ingeschakeld (nacht)", disarmed: "Uitgeschakeld", triggered: "Geactiveerd", + }; + + const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; + function detect() { + const pref = read("ds.lang"); + if (pref && pref !== "auto") return pref; + let ha = read("selectedLanguage"); // Home Assistant stores it as a JSON string + try { ha = ha && JSON.parse(ha); } catch { /* plain string */ } + return String(ha || navigator.language || "en").toLowerCase().startsWith("nl") ? "nl" : "en"; + } + + const lang = detect(); + const dict = lang === "nl" ? NL : {}; + const cap = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : "").replace(/_/g, " "); + + window.I18n = { + lang, + locale: lang === "nl" ? "nl-NL" : undefined, + languages: { auto: "Automatic", en: "English", nl: "Nederlands" }, + preference: () => read("ds.lang") || "auto", + /** t("Hello {0}", name) */ + t(text, ...args) { + const s = dict[text] ?? text; + return args.length ? s.replace(/\{(\d)\}/g, (_, i) => args[i] ?? "") : s; + }, + /** A Home Assistant state as readable text. */ + state: (st) => (lang === "nl" && STATES_NL[st]) || cap(st), + }; + document.documentElement.lang = lang; +})(); diff --git a/dashboard_studio/web/icons.js b/dashboard_studio/web/icons.js new file mode 100644 index 0000000..1e043b5 --- /dev/null +++ b/dashboard_studio/web/icons.js @@ -0,0 +1,91 @@ +/* One consistent line-icon set (24px grid, 1.8 stroke, round joins). + * Icons.svg(name) returns inline SVG; unknown names return "" so callers can + * fall back to whatever text (e.g. an emoji) the person typed. */ +(function () { + const P = { + bulb: "M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.6.5 1 1.2 1.1 2V17h5v-1.2c.1-.8.5-1.5 1.1-2A6 6 0 0 0 12 3z", + plug: "M9 3v5M15 3v5M7 8h10v3a5 5 0 0 1-10 0V8zM12 16v5", + fan: "M12 13.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM12 10.5C12 6 13 3 15.5 3S19 5.5 16 8.5L13.3 11M13.4 12.8c3.8 2.3 5.9 4.7 4.6 6.9s-4.3 1.3-5.4-2.8L12 13.5M10.7 12.6C6.8 14.8 3.7 15.4 2.4 13.2s1-4.2 5-3l3.2.8", + thermometer: "M10 13.5V5a2 2 0 1 1 4 0v8.5a4 4 0 1 1-4 0zM12 9v6", + droplet: "M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11z", + bolt: "M13 2L4 14h7l-1 8 9-12h-7l1-8z", + battery: "M3 8h15a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM21.5 11v2", + door: "M6 21V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v17M4 21h16M14.5 12.5v.01", + "door-open": "M4 21h16M6 21V4a1 1 0 0 1 1-1h4M11 3l7 2.5V21M11 3v18M14 12.5v.01", + window: "M5 3h14v18H5zM12 3v18M5 12h14", + person: "M12 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 21v-2a6 6 0 0 1 12 0v2", + lock: "M6 11h12v10H6zM8 11V7a4 4 0 0 1 8 0v4", + unlock: "M6 11h12v10H6zM8 11V7a4 4 0 0 1 7.5-2", + speaker: "M11 5L6 9H3v6h3l5 4V5zM15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13", + music: "M9 18V5l11-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM20 16a3 3 0 1 1-6 0 3 3 0 0 1 6 0z", + sparkles: "M11 3l1.8 4.7 4.7 1.8-4.7 1.8L11 16l-1.8-4.7L4.5 9.5l4.7-1.8zM18.5 14l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z", + script: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM10 8.5l5 3.5-5 3.5z", + camera: "M4 7h3l2-3h6l2 3h3a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", + home: "M3 11l9-8 9 8M5 9.5V21h14V9.5M10 21v-6h4v6", + car: "M3 13l2-5.2A2 2 0 0 1 6.9 6.5h10.2A2 2 0 0 1 19 7.8L21 13v4a1 1 0 0 1-1 1h-.5M3 13v4a1 1 0 0 0 1 1h.5M3 13h18M8 18h8M8 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM20 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z", + sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1.5v2M12 20.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M1.5 12h2M20.5 12h2M4.6 19.4L6 18M18 6l1.4-1.4", + moon: "M20.5 13.5A8.5 8.5 0 1 1 10.5 3.5a6.5 6.5 0 0 0 10 10z", + cloud: "M7 19a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 9.5a4.75 4.75 0 0 1-.5 9.5z", + "cloud-sun": "M8.5 6.2A4 4 0 0 1 15 7M5 9.5L3.8 9M8.5 2.5V1.5M3.5 4.5l1 1M8 21a3.8 3.8 0 0 1-.5-7.6 5.3 5.3 0 0 1 10.2-1A4.3 4.3 0 0 1 17.5 21z", + rain: "M7 15a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 5.5a4.75 4.75 0 0 1-.5 9.5zM8 22l1-3M12 22l1-3M16 22l1-3", + snow: "M7 15a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 5.5a4.75 4.75 0 0 1-.5 9.5zM8 19v.01M12 19v.01M16 19v.01M10 22v.01M14 22v.01", + storm: "M7 15a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 5.5a4.75 4.75 0 0 1-.5 9.5zM12.5 14l-2 4h3.5l-2 4", + fog: "M4 9h16M4 13h16M6 17h12M8 5h8", + wind: "M3 8h11a3 3 0 1 0-3-3M3 12h16a3 3 0 1 1-3 3M3 16h8", + plus: "M12 5v14M5 12h14", + minus: "M5 12h14", + gauge: "M4.2 18a9 9 0 1 1 15.6 0M12 14l4-5", + power: "M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0", + garage: "M3 21V9l9-6 9 6v12M7 21v-9h10v9M7 15h10M7 18h10", + shield: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z", + bin: "M4 6h16M6 6l1.2 12.2a1 1 0 0 0 1 .8h7.6a1 1 0 0 0 1-.8L18 6M9 6V3.5h6V6M8.5 21.5a1 1 0 1 0 0-.01M15.5 21.5a1 1 0 1 0 0-.01M10 10v5M14 10v5", + tabs: "M3 5h7v14H3zM13 6h8M13 10h8M13 14h5", + 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", + list: "M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01", + text: "M4 6h16M4 12h16M4 18h10", + heading: "M6 4v16M18 4v16M6 12h12", + image: "M4 4h16v16H4zM4 16l5-5 4 4 3-3 4 4M15 9a1 1 0 1 0 0-.01", + globe: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.5 5.5 3.5 9s-1 6.5-3.5 9c-2.5-2.5-3.5-5.5-3.5-9s1-6.5 3.5-9z", + tile: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z", + number: "M5 9h14M5 15h14M10 4L8 20M16 4l-2 16", + button: "M6 7h12a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-4a3 3 0 0 1 3-3zM9 12h6", + undo: "M9 14L4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3", + redo: "M15 14l5-5-5-5M20 9H9a5 5 0 0 0 0 10h3", + more: "M5 12h.01M12 12h.01M19 12h.01", + expand: "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5", + close: "M6 6l12 12M18 6L6 18", + copy: "M9 9h11v11H9zM5 15H4V4h11v1", + trash: "M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3", + kiosk: "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-1zM11 18h2", + edit: "M4 20h4L19 9l-4-4L4 16zM14 6l4 4", + check: "M5 12l5 5 9-10", + chevron: "M6 9l6 6 6-6", + cable: "M7 3v4M12 3v4M5 7h9v3a4.5 4.5 0 0 1-9 0zM9.5 14.5V17a3 3 0 0 0 3 3h3a3 3 0 0 0 3-3V8", + pin: "M12 21s7-6.2 7-11.5a7 7 0 0 0-14 0C5 14.8 12 21 12 21zM12 12a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z", + toggle: "M7 7h10a5 5 0 0 1 0 10H7A5 5 0 0 1 7 7zM16 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z", + robot: "M5 9h14v10H5zM12 5v4M12 5h.01M9 13h.01M15 13h.01M9 16h6", + flame: "M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2.5 1.5-4 2.5-5 .5 2 1.5 3 2.5 3 0-3-1-5 0-8z", + pulse: "M3 12h4l3-7 4 14 3-7h4", + vacuum: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", + snowflake: "M12 2v20M4 7l16 10M20 7L4 17M9 3.5l3 2 3-2M9 20.5l3-2 3 2", + exit: "M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M10 16l-4-4 4-4M6 12h10", + road: "M8 3L4 21M16 3l4 18M12 4v3M12 10.5v3M12 17v3", + // filled glyphs + play: "M7 4.5v15a.8.8 0 0 0 1.2.7l12-7.5a.8.8 0 0 0 0-1.4l-12-7.5A.8.8 0 0 0 7 4.5z", + pause: "M6 4h4v16H6zM14 4h4v16h-4z", + next: "M4 5.2v13.6a.7.7 0 0 0 1.1.6L15 12.6a.7.7 0 0 0 0-1.2L5.1 4.6a.7.7 0 0 0-1.1.6zM17 4h3v16h-3z", + prev: "M20 5.2v13.6a.7.7 0 0 1-1.1.6L9 12.6a.7.7 0 0 1 0-1.2l9.9-6.8a.7.7 0 0 1 1.1.6zM4 4h3v16H4z", + }; + const FILLED = new Set(["play", "pause", "next", "prev"]); + + window.Icons = { + names: Object.keys(P).filter((n) => !FILLED.has(n)), + has: (n) => Object.prototype.hasOwnProperty.call(P, n), + svg(name, cls = "") { + if (!this.has(name)) return ""; + return ``; + }, + }; +})(); diff --git a/dashboard_studio/web/index.html b/dashboard_studio/web/index.html new file mode 100644 index 0000000..86de590 --- /dev/null +++ b/dashboard_studio/web/index.html @@ -0,0 +1,72 @@ + + + + + + + + Dashboard Studio + + + +
+ + +
+ + +
+ +
+
+ + +
+
+ + + + +
+ + + + + + + + diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css new file mode 100644 index 0000000..d070bf7 --- /dev/null +++ b/dashboard_studio/web/style.css @@ -0,0 +1,529 @@ +/* ============================================================ tokens + * Every color has a light and dark value; contrast figures are against the tile. + * text #1d1d1f / #f5f5f7 16.8:1 / 15.6:1 + * text-2 #6e6e73 / #a1a1a6 5.1:1 / 6.6:1 + * accent #0066cc / #0a84ff (white label 5.6:1 / 3.7:1, semibold) + * lit #ffedc7 / #ffe2a8 (lit-text 6.2:1+ for secondary, 10.7:1 primary) + */ +:root { + --canvas: #f2f2f7; + --surface: #ffffff; + --tile: #ffffff; + --text: #1d1d1f; + --text-2: #6e6e73; + --separator: rgba(60, 60, 67, 0.16); + --fill: rgba(120, 120, 128, 0.14); + --fill-strong: rgba(120, 120, 128, 0.24); + --accent: #0066cc; + --on-accent: #ffffff; + --lit: #ffedc7; + --lit-text: #3d2c0a; + --lit-text-2: #6b5426; + --warn: #c93400; + --crit: #d70015; + --ok: #248a3d; + --glass: rgba(255, 255, 255, 0.72); + --glass-edge: rgba(255, 255, 255, 0.6); + --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 16px rgba(0, 0, 0, 0.06); + --focus: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); + --font: -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-num: ui-rounded, "SF Pro Rounded", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + color-scheme: light dark; +} +@media (prefers-color-scheme: dark) { + :root { + --canvas: #0f0f11; + --surface: #1c1c1e; + --tile: #1c1c1e; + --text: #f5f5f7; + --text-2: #a1a1a6; + --separator: rgba(84, 84, 88, 0.55); + --fill: rgba(118, 118, 128, 0.24); + --fill-strong: rgba(118, 118, 128, 0.36); + --accent: #0a84ff; + --lit: #ffe2a8; + --warn: #ff9f0a; + --crit: #ff453a; + --ok: #30d158; + --glass: rgba(30, 30, 32, 0.68); + --glass-edge: rgba(255, 255, 255, 0.08); + --shadow: none; + } +} +@media (prefers-contrast: more) { + :root { --text-2: var(--text); --separator: color-mix(in srgb, var(--text) 45%, transparent); --glass: var(--surface); } + .w { border-color: var(--d-separator) !important; } +} +@media (prefers-reduced-transparency: reduce) { + :root { --glass: var(--surface); } +} + +/* ============================================================ base */ +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + font: 14px/1.4 var(--font); + background: var(--canvas); + color: var(--text); + overflow: hidden; + -webkit-font-smoothing: antialiased; +} +button, input, select, textarea { font: inherit; color: inherit; } +h2, h3 { margin: 0; } +.muted { color: var(--text-2); } +.small { font-size: 13px; } +.num { font-family: var(--font-num); font-variant-numeric: tabular-nums; } +.i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; } +.i.f { fill: currentColor; stroke: none; } +.i.inline { vertical-align: -2px; margin-right: 3px; } +:focus { outline: none; } +:focus-visible { box-shadow: var(--focus); border-radius: 8px; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +/* ============================================================ chrome */ +#app { position: relative; height: 100vh; } + +/* Floating toolbar: the one glass surface, part of the functional layer. */ +#topbar { + position: absolute; top: 10px; left: 10px; right: 10px; z-index: 30; + height: 52px; padding: 0 8px 0 10px; + display: flex; align-items: center; justify-content: space-between; gap: 8px; + background: var(--glass); + backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4); + border: 1px solid var(--glass-edge); + border-radius: 18px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); +} +.tb-group { display: flex; align-items: center; gap: 4px; min-width: 0; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + min-height: 36px; min-width: 36px; padding: 0 14px; + border: 0; border-radius: 10px; background: var(--fill); cursor: pointer; + font-weight: 500; white-space: nowrap; +} +.btn:hover { background: var(--fill-strong); } +.btn .i { font-size: 18px; } +.btn.icon { padding: 0; width: 36px; background: transparent; } +.btn.icon:hover { background: var(--fill); } +.btn.primary { background: var(--accent); color: var(--on-accent); font-weight: 600; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.danger { color: var(--crit); } +.btn.danger-fill { background: var(--crit); color: #fff; font-weight: 600; } +.btn:disabled { opacity: 0.35; cursor: default; } + +.picker { position: relative; display: flex; align-items: center; } +.picker select { + appearance: none; -webkit-appearance: none; + border: 0; background: transparent; cursor: pointer; + font-size: 17px; font-weight: 650; padding: 6px 28px 6px 8px; border-radius: 10px; max-width: 46vw; + text-overflow: ellipsis; +} +.picker select:hover { background: var(--fill); } +.picker .i { position: absolute; right: 8px; pointer-events: none; font-size: 14px; color: var(--text-2); } + +.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--crit); padding: 0 6px; } +.conn::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } +.conn.ok { display: none; } +.save-status { font-size: 13px; color: var(--text-2); padding: 0 6px; white-space: nowrap; } + +.menu { + position: absolute; right: 8px; top: calc(100% + 6px); z-index: 50; + background: var(--glass); backdrop-filter: blur(30px) saturate(1.4); -webkit-backdrop-filter: blur(30px) saturate(1.4); + border: 1px solid var(--glass-edge); border-radius: 14px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); padding: 6px; display: flex; flex-direction: column; min-width: 220px; +} +.menu[hidden] { display: none; } +.menu button { display: flex; align-items: center; gap: 10px; border: 0; background: transparent; text-align: left; padding: 9px 10px; border-radius: 8px; cursor: pointer; min-height: 36px; } +.menu button .i { font-size: 17px; color: var(--text-2); } +.menu button:hover { background: var(--fill); } +.menu .danger, .menu .danger .i { color: var(--crit); } +.menu hr { border: 0; border-top: 1px solid var(--separator); margin: 4px 6px; } + +#workspace { position: absolute; inset: 0; display: flex; } +#palette, #inspector { + flex: none; overflow-y: auto; background: var(--surface); + padding: 76px 14px 20px; +} +#palette { width: 232px; border-right: 1px solid var(--separator); } +#inspector { width: 300px; border-left: 1px solid var(--separator); } +.panel-title { font-size: 17px; font-weight: 650; margin: 2px 2px 12px; display: flex; align-items: center; gap: 8px; } +.panel-title .i { font-size: 20px; color: var(--text-2); } +.hint { margin: 16px 2px 0; font-size: 13px; color: var(--text-2); } + +#palette-list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.pal-item { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; + min-height: 72px; padding: 10px 4px; border-radius: 12px; border: 0; background: var(--fill); + font-size: 13px; text-align: center; cursor: pointer; +} +.pal-item:hover { background: var(--fill-strong); } +.pal-item .i { font-size: 22px; } +.pal-item span { hyphens: auto; -webkit-hyphens: auto; overflow-wrap: anywhere; line-height: 1.2; } + +body:not(.editing) .edit-only { display: none !important; } +body.editing .view-only { display: none !important; } + +#stage { flex: 1; overflow: auto; min-width: 0; position: relative; padding-top: 72px; display: flex; align-items: flex-start; } +#canvas { flex: 1; min-width: 0; align-self: stretch; } +#grid { position: relative; margin: 0 auto; } + +/* inspector form: grouped like a settings list */ +.field { margin-bottom: 12px; } +.field > label, .field-label { display: block; font-size: 13px; color: var(--text-2); margin: 0 2px 4px; } +.field.check > label { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 14px; min-height: 28px; cursor: pointer; } +.field.check input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; } +.section { border-top: 1px solid var(--separator); margin-top: 16px; padding-top: 14px; } +.section-title { font-size: 13px; font-weight: 600; color: var(--text-2); margin: 0 2px 10px; } +.row { display: flex; gap: 8px; align-items: flex-end; } +.row > * { flex: 1; min-width: 0; } +select.input, input.input, textarea.input { + width: 100%; min-width: 0; min-height: 34px; padding: 6px 10px; + background: var(--fill); border: 1px solid transparent; border-radius: 9px; +} +input.input:focus, select.input:focus, textarea.input:focus { border-color: var(--accent); box-shadow: var(--focus); background: var(--surface); } +textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace, Menlo, monospace; font-size: 13px; } +.input.invalid { border-color: var(--crit); } +.seg { display: flex; background: var(--fill); border-radius: 9px; padding: 2px; gap: 2px; } +.seg button { flex: 1; border: 0; background: transparent; border-radius: 7px; min-height: 30px; padding: 0 6px; cursor: pointer; font-size: 13px; white-space: nowrap; } +.seg button[aria-pressed="true"] { background: var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); font-weight: 600; } +.color-row { display: flex; gap: 6px; } +.color-row input[type=color] { width: 34px; height: 34px; padding: 0; border: 1px solid var(--separator); border-radius: 9px; background: none; flex: none; cursor: pointer; } +.icon-row { display: flex; gap: 6px; align-items: center; } +.icon-row .icon-prev { width: 34px; height: 34px; border-radius: 9px; background: var(--fill); display: grid; place-items: center; flex: none; font-size: 18px; } +.ent-item { display: flex; gap: 6px; margin-bottom: 6px; } +.ent-item .btn { flex: none; } +.ent-hint { font-size: 13px; color: var(--text-2); margin: 4px 2px 0; } +.insp-actions { display: flex; gap: 8px; margin-top: 18px; } +.insp-actions .btn { flex: 1; } +.presets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.preset { height: 52px; border-radius: 10px; border: 2px solid transparent; padding: 0; position: relative; overflow: hidden; cursor: pointer; } +.preset span { position: absolute; left: 7px; bottom: 5px; font-size: 12px; font-weight: 600; } +.preset i { position: absolute; right: 7px; top: 7px; width: 16px; height: 16px; border-radius: 5px; } +.preset[aria-pressed="true"] { border-color: var(--accent); } + +/* sheets, alerts, toasts */ +.modal { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 16px; } +.modal[hidden] { display: none; } +.modal-card { background: var(--surface); border-radius: 18px; padding: 20px; width: 420px; max-width: 100%; max-height: 85vh; overflow: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); } +.modal-card h3 { font-size: 20px; font-weight: 700; margin-bottom: 4px; } +.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; } +.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%; } +#toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; } +.toast { background: var(--glass); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border: 1px solid var(--glass-edge); color: var(--text); padding: 10px 16px; border-radius: 14px; font-size: 14px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); } +.toast.err { color: var(--crit); font-weight: 500; } + +/* ============================================================ dashboard canvas + * Themed via --d-* on #stage. "System" leaves them on the adaptive tokens. */ +#stage { + --d-bg: var(--canvas); + --d-tile: var(--tile); + --d-text: var(--text); + --d-text-2: var(--text-2); + --d-accent: var(--accent); + --d-separator: var(--separator); + --d-fill: var(--fill); + --d-lit: var(--lit); + --d-shadow: var(--shadow); + background: var(--d-bg); + background-size: cover; background-position: center; background-attachment: local; + color: var(--d-text); +} +.w { + position: absolute; + border-radius: var(--d-radius, 20px); + background: var(--w-bg, var(--d-tile)); + color: var(--w-color, var(--d-text)); + --accent-w: var(--w-accent, var(--d-accent)); + box-shadow: var(--d-shadow); + border: 1px solid transparent; + backdrop-filter: blur(var(--d-blur, 0px)); -webkit-backdrop-filter: blur(var(--d-blur, 0px)); + overflow: hidden; + transition: left .2s, top .2s, width .2s, height .2s, background-color .25s; + user-select: none; + container-type: size; +} +.w.transparent, .surface-plain .w { background: transparent; box-shadow: none; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; } +.surface-outline .w:not(.transparent) { background: transparent; box-shadow: none; border-color: var(--d-separator); backdrop-filter: none; } +.w-body { position: absolute; inset: 0; padding: 16px; display: flex; flex-direction: column; min-height: 0; } +.w-body.flush { padding: 0; } +.w.transparent .w-body, .surface-plain .w .w-body { padding: 6px 4px; } +.pad { padding: 16px; height: 100%; display: flex; } + +/* Lit: a light that is on turns its tile into lamplight — the signature moment. */ +.w.lit-lamp:not(.transparent) { + background: var(--w-bg, color-mix(in srgb, var(--lamp, var(--d-lit)) 32%, var(--d-lit))); + color: var(--lit-text); + --d-text-2: var(--lit-text-2); + --d-fill: rgba(61, 44, 10, 0.12); +} +.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; } + +/* edit mode */ +body.editing #grid { background-image: var(--cell-bg); background-repeat: repeat; min-height: calc(100% - 16px); } +body.editing .w { cursor: grab; } +body.editing .w-body { pointer-events: none; } +body.editing .surface-plain .w, body.editing .w.transparent { outline: 1px dashed var(--d-separator); outline-offset: -1px; } +body.editing .w.selected { outline: 2px solid var(--accent); outline-offset: 2px; z-index: 5; } +.w.dragging { transition: none; z-index: 20; cursor: grabbing; box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); } +.w-resize { position: absolute; right: 2px; bottom: 2px; width: 24px; height: 24px; cursor: nwse-resize; z-index: 3; opacity: 0; transition: opacity .1s; color: var(--accent); } +.w-resize::after { content: ""; position: absolute; right: 5px; bottom: 5px; width: 10px; height: 10px; border-right: 3px solid currentColor; border-bottom: 3px solid currentColor; border-bottom-right-radius: 4px; } +.w-tools { position: absolute; top: 8px; right: 8px; z-index: 3; display: none; gap: 4px; } +.w-tools button { width: 30px; height: 30px; border-radius: 9px; border: 0; display: grid; place-items: center; background: var(--surface); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); cursor: pointer; font-size: 16px; } +.w-tools button.del { color: var(--crit); } +body.editing .w.selected .w-tools { display: flex; } +body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1; } +.placeholder { position: absolute; border-radius: var(--d-radius, 20px); background: color-mix(in srgb, var(--accent) 16%, transparent); border: 2px dashed var(--accent); pointer-events: none; z-index: 1; } + +/* ============================================================ widget content */ +.wbtn { all: unset; box-sizing: border-box; cursor: pointer; -webkit-tap-highlight-color: transparent; } +.wbtn:focus-visible { box-shadow: var(--focus); border-radius: 12px; } +.wt { font-size: 14px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wt.wrap { white-space: normal; } +.empty { margin: auto; text-align: center; color: var(--d-text-2); font-size: 14px; line-height: 1.45; } +.name { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.glyph { font-size: 20px; line-height: 1; } +.badge { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-size: 22px; flex: none; transition: background-color .25s, color .25s; } +.level { align-self: flex-start; font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 99px; color: var(--warn); background: color-mix(in srgb, currentColor 16%, transparent); } + +.heading { display: flex; flex-direction: column; justify-content: center; height: 100%; gap: 2px; } +.heading .h-main { font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; } + +.tile { display: flex; flex-direction: column; justify-content: space-between; height: 100%; width: 100%; gap: 8px; } +.tile-text { display: flex; flex-direction: column; min-width: 0; } +.state { font-size: 14px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +@container (max-height: 110px) { .tile { flex-direction: row; align-items: center; justify-content: flex-start; } } + +.value { display: flex; flex-direction: column; justify-content: space-between; height: 100%; width: 100%; gap: 4px; } +.value-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.value-ico { font-size: 20px; color: var(--d-text-2); display: flex; } +.big { font-size: clamp(26px, min(30cqh, 24cqw), 56px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; } +.unit { font-size: 0.42em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; } +.value.warn .big { color: var(--warn); } +.value.crit .big { color: var(--crit); } +.value.warn .level { color: var(--warn); } +.value.crit .level { color: var(--crit); } +.car .level { color: var(--crit); align-self: center; } + +.gauge { display: flex; flex-direction: column; align-items: center; height: 100%; width: 100%; } +.gauge svg { flex: 1; width: 100%; min-height: 0; } + +.light { display: flex; flex-direction: column; height: 100%; gap: 10px; justify-content: space-between; } +.tile-row { display: flex; align-items: center; gap: 12px; min-width: 0; } +/* Brightness: a wide pill slider like Control Center, filled with the lamp's color. */ +.slider { + -webkit-appearance: none; appearance: none; width: 100%; height: 44px; margin: 0; border-radius: 14px; cursor: pointer; + background: linear-gradient(to right, var(--slider-fill, var(--d-text)) var(--pct, 0%), var(--d-fill) var(--pct, 0%)); + outline: none; +} +.w.lit-lamp .slider { --slider-fill: color-mix(in srgb, var(--lamp, #f0a83a) 65%, #f0a83a); } +.surface-plain .w.lit-lamp .slider, .surface-outline .w.lit-lamp .slider { --slider-fill: color-mix(in srgb, var(--lamp, #f5b942) 60%, #f5b942); } +.slider::-webkit-slider-thumb { -webkit-appearance: none; width: 6px; height: 26px; border-radius: 3px; background: transparent; } +.slider::-moz-range-thumb { width: 6px; height: 26px; border: 0; background: transparent; } +.slider:focus-visible { box-shadow: var(--focus); } +.slider.thin { height: 28px; border-radius: 10px; flex: 1; min-width: 60px; } + +.climate { display: flex; flex-direction: column; height: 100%; gap: 8px; } +.climate .temps { display: flex; align-items: center; justify-content: space-between; flex: 1; gap: 8px; } +.climate .target { font-size: clamp(28px, min(22cqh, 18cqw), 56px); font-weight: 700; } +.round { width: 44px; height: 44px; border-radius: 50%; background: var(--d-fill); display: grid; place-items: center; font-size: 20px; flex: none; } +.round:hover { background: color-mix(in srgb, var(--d-text) 16%, transparent); } +.round.primary { background: var(--d-text); color: var(--d-tile); } +.chips { display: flex; gap: 6px; flex-wrap: wrap; } +.chip { border-radius: 99px; padding: 0 14px; min-height: 32px; display: inline-flex; align-items: center; font-size: 13px; font-weight: 500; background: var(--d-fill); } +.chip[aria-pressed="true"] { background: var(--d-text); color: var(--d-tile); font-weight: 600; } + +.graph { display: flex; flex-direction: column; height: 100%; gap: 6px; } +.graph .legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 13px; color: var(--d-text-2); } +.graph .legend span { display: inline-flex; align-items: center; gap: 6px; } +.graph .legend svg { flex: none; } +.graph .legend b { font-size: 15px; color: var(--d-text); } +.graph .plot { flex: 1; min-height: 0; position: relative; } +.graph .plot svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } +.graph .axis { font-size: 11px; fill: var(--d-text-2); } + +.clock { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; text-align: center; } +.clock .time { font-size: clamp(30px, min(38cqh, 22cqw), 96px); font-weight: 700; letter-spacing: -0.03em; line-height: 1; } +.clock .date { color: var(--d-text-2); margin-top: 6px; font-size: 15px; } + +.cam-btn { position: absolute; inset: 0; } +.cam { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; } +.cam-label { position: absolute; left: 12px; bottom: 10px; font-size: 13px; font-weight: 600; color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); pointer-events: none; } +.media { display: flex; flex-direction: column; height: 100%; gap: 6px; justify-content: space-between; } +.media .title { font-weight: 650; font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.media .ctrls { display: flex; gap: 8px; align-items: center; } +.weather { display: flex; align-items: center; gap: 16px; height: 100%; width: 100%; } +.wx-ico { font-size: clamp(36px, min(45cqh, 20cqw), 72px); display: flex; color: var(--d-text-2); } +.wx-text { display: flex; flex-direction: column; min-width: 0; } +.wx-temp { font-size: clamp(26px, min(26cqh, 16cqw), 48px); font-weight: 700; line-height: 1.05; } +.list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; margin: 4px -6px 0; } +.list-row { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 6px; border-radius: 10px; } +.list-row + .list-row { box-shadow: inset 0 1px 0 var(--d-separator); } +.list-row:hover { background: var(--d-fill); } +.row-ico { font-size: 20px; display: flex; color: var(--d-text-2); } +.list-row .grow { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 15px; } +.list-row .st { color: var(--d-text-2); font-size: 14px; white-space: nowrap; } +.switch { width: 46px; height: 28px; border-radius: 14px; background: var(--d-fill); position: relative; flex: none; transition: background-color .2s; } +.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 24px; height: 24px; border-radius: 50%; background: #fff; transition: transform .2s; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); } +[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; } +.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; } + +/* tab menu: a sidebar on wide screens, a rail on tablets, a tab bar on phones */ +#tabnav { + position: sticky; top: 4px; flex: none; z-index: 10; + width: 220px; margin: 8px 0 16px 16px; padding: 8px; + display: flex; flex-direction: column; gap: 2px; + max-height: calc(100vh - 92px); overflow-y: auto; + background: color-mix(in srgb, var(--d-tile) 82%, transparent); + backdrop-filter: blur(24px) saturate(1.3); -webkit-backdrop-filter: blur(24px) saturate(1.3); + border: 1px solid var(--d-separator); border-radius: var(--d-radius, 20px); + color: var(--d-text); +} +#tabnav[hidden] { display: none; } +.tabnav-item { + display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 12px; + border: 0; border-radius: 12px; background: transparent; color: inherit; cursor: pointer; + font-size: 15px; font-weight: 500; text-align: left; +} +.tabnav-item:hover { background: var(--d-fill); } +.tabnav-item[aria-current="page"] { background: color-mix(in srgb, var(--d-accent) 18%, transparent); font-weight: 650; } +.tabnav-ico { font-size: 20px; display: flex; color: var(--d-text-2); flex: none; } +.tabnav-item[aria-current="page"] .tabnav-ico { color: var(--d-accent); } +.tabnav-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tabnav-item.add { color: var(--d-text-2); margin-top: 4px; } +.tabnav-item.add .i { font-size: 20px; } +body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } +.surface-plain #tabnav { background: transparent; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; } +.i.flip { transform: rotate(180deg); } +.tab-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; } +.tab-item { display: flex; gap: 4px; align-items: center; } +.tab-item .input { min-width: 0; } +.tab-item .tab-icon { width: 76px; flex: none; } +.tab-item .icon-prev { width: 30px; height: 30px; font-size: 16px; } +.tab-item .btn.icon { width: 30px; min-width: 30px; flex: none; } + +/* waste */ +.bin { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: var(--bin); color: #fff; font-size: 19px; flex: none; } +.w-body > .wt { flex: none; } +.waste-next { flex: none; display: flex; align-items: center; gap: 14px; padding: 8px 0 12px; } +.waste-next + .waste-list { border-top: 1px solid var(--d-separator); padding-top: 4px; } +.waste-bins { display: flex; } +.waste-bins .bin { width: 52px; height: 52px; border-radius: 14px; font-size: 28px; } +.waste-bins .bin + .bin { margin-left: -12px; box-shadow: 0 0 0 3px var(--d-tile); } +.waste-next-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; } +.waste-when { font-size: 24px; font-weight: 700; line-height: 1.15; } +.waste-next.soon .waste-when { color: var(--d-accent); } +.waste-types { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.waste-next .level { color: var(--d-accent); margin-top: 4px; } +.waste-row .bin { width: 28px; height: 28px; border-radius: 8px; font-size: 16px; } +.waste-day { font-size: 14px; font-weight: 600; white-space: nowrap; } +.waste-day.soon { color: var(--d-accent); } +@container (max-width: 300px) { .waste-row .st { display: 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; } +.ph-layer.on { opacity: 1; } +.ph-msg { position: absolute; inset: 0; display: flex; padding: 16px; color: #fff; --d-text-2: rgba(255, 255, 255, 0.75); } +.ph-msg:empty { display: none; } +.ph-date { position: absolute; left: 14px; bottom: 12px; font-size: 13px; font-weight: 600; color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); } +.albums { display: flex; flex-direction: column; gap: 2px; background: var(--fill); border-radius: 9px; padding: 4px; max-height: 220px; overflow-y: auto; } +.album { display: flex; align-items: center; gap: 10px; min-height: 36px; padding: 0 8px; border-radius: 7px; cursor: pointer; } +.album:hover { background: var(--fill); } +.album input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; } +.album .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* calendar */ +.cal { flex: 1; min-height: 0; overflow-y: auto; margin: 4px -4px 0; padding: 0 4px; } +.cal-day { font-size: 13px; font-weight: 650; color: var(--d-text-2); margin: 12px 0 4px; } +.cal-day:first-child { margin-top: 4px; } +.cal-ev { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; } +.cal-bar { width: 4px; align-self: stretch; min-height: 20px; border-radius: 2px; flex: none; } +.cal-when { width: 92px; flex: none; font-size: 14px; color: var(--d-text-2); padding-top: 1px; } +.cal-what { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.cal-sum { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cal-sub { font-size: 13px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cal-now { font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 99px; color: var(--d-accent); background: color-mix(in srgb, currentColor 16%, transparent); align-self: center; } +@container (max-width: 260px) { .cal-ev { flex-wrap: wrap; } .cal-when { width: auto; order: 2; flex-basis: 100%; padding-left: 14px; } } + +/* car */ +.car { position: relative; display: flex; flex-direction: column; height: 100%; gap: 8px; } +.car-img { position: absolute; right: -4px; top: -6px; width: 46%; height: 58%; object-fit: contain; object-position: right top; pointer-events: none; } +.car.has-img .car-head, .car.has-img .car-main { max-width: 56%; } +.car-head { display: flex; flex-direction: column; min-width: 0; } +.car-head .name { font-size: 17px; } +.car-main { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-top: auto; } +.car-range { font-size: 17px; font-weight: 600; } +.car-range .wt { font-size: 14px; font-weight: 400; } +.car-bar { position: relative; height: 12px; border-radius: 6px; background: var(--d-fill); overflow: visible; flex: none; } +.car-fill { position: absolute; inset: 0 auto 0 0; border-radius: 6px; background: var(--d-text); transition: width .4s; } +.car-bar.charging .car-fill { background: var(--ok); background-image: linear-gradient(110deg, transparent 40%, rgba(255, 255, 255, 0.45) 50%, transparent 60%); background-size: 200% 100%; animation: charge 2.4s linear infinite; } +.car-bar.low .car-fill { background: var(--crit); } +.car-limit { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); } +@keyframes charge { from { background-position: 100% 0; } to { background-position: -100% 0; } } +.car-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.car-act { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 12px; background: var(--d-fill); font-size: 14px; font-weight: 500; } +.car-act .i { font-size: 18px; } +.car-act[aria-pressed="true"] { background: var(--d-text); color: var(--d-tile); } +.car-odo { margin-left: auto; } +@container (max-width: 340px) { .car-img { display: none; } .car.has-img .car-head, .car.has-img .car-main { max-width: none; } .car-odo { display: none; } } +@container (max-height: 200px) { .car-actions { display: none; } } + +/* ============================================================ kiosk */ +body.kiosk #topbar, body.kiosk #palette, body.kiosk #inspector { display: none !important; } +body.kiosk #stage { padding-top: 6px; } +body.kiosk.idle, body.kiosk.idle * { cursor: none !important; } +#saver { position: fixed; inset: 0; z-index: 80; background: #000; color: #8e8e93; display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: none; } +#saver[hidden] { display: none; } +#saver .time { font-size: min(22vw, 220px); font-weight: 600; letter-spacing: -0.03em; line-height: 1; } +#saver .date { font-size: 22px; margin-top: 12px; color: #636366; } + +/* ============================================================ motion + sizes */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; animation: none !important; } +} +/* While editing (and on tablets) the menu becomes a compact rail to leave room for the grid. */ +@media (min-width: 641px) { + body.editing #tabnav { width: 84px; padding: 6px; margin-left: 10px; } + body.editing .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; } + body.editing .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; } +} +@media (max-width: 900px) { + #tabnav { width: 84px; padding: 6px; margin-left: 10px; } + .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; } + .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; } + #palette { width: 180px; } + #inspector { width: 260px; } + #palette-list { grid-template-columns: 1fr; } + .save-status { display: none; } +} +@media (max-width: 640px) { + body:not(.editing) #tabnav { + position: fixed; top: auto; left: 10px; right: 10px; bottom: calc(10px + env(safe-area-inset-bottom)); width: auto; margin: 0; + flex-direction: row; max-height: none; overflow-x: auto; padding: 6px; border-radius: 22px; + background: var(--glass); border-color: var(--glass-edge); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + } + body:not(.editing) #tabnav .tabnav-item { flex: 1; min-width: 64px; } + body.has-tabs:not(.editing) #canvas { padding-bottom: 96px; } + body.editing #tabnav { position: static; width: auto; flex-direction: row; overflow-x: auto; max-height: none; margin: 4px 10px; } + body.editing #stage { flex-direction: column; align-items: stretch; } + #workspace { flex-direction: column; } + body.editing #stage { order: 1; flex: 1; } + body.editing #palette { order: 2; width: auto; max-height: 120px; padding: 10px; border: 0; border-top: 1px solid var(--separator); } + body.editing #palette .panel-title, body.editing #palette .hint { display: none; } + body.editing #palette-list { grid-template-columns: repeat(4, 1fr); } + body.editing #inspector { order: 3; width: auto; max-height: 40vh; padding: 14px; border: 0; border-top: 1px solid var(--separator); } + .btn.label-wide span { display: none; } +} diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js new file mode 100644 index 0000000..9377a11 --- /dev/null +++ b/dashboard_studio/web/widgets.js @@ -0,0 +1,718 @@ +/* Widget library. Each widget type declares: + * label, icon, size [w, h], fields (inspector schema), render(cfg, ctx) -> HTML + * optional: entities(cfg) -> ids it depends on, after(el, cfg, ctx), flush, transparent, + * lit(cfg, ctx) -> "lamp" | "on" | false, lamp(cfg, ctx) -> CSS color for a lit light + * Interactive elements are real `; + }, + lit: (c, { ha }) => litKind(ha.states[c.entity]), + lamp: (c, { ha }) => lampColor(ha.states[c.entity]), + }; + + W.value = { + label: "Big value", icon: "number", size: [3, 2], + fields: [ + { k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number", "counter"] }, + { k: "name", t: "text", l: "Name", ph: "Entity name" }, + { k: "unit", t: "text", l: "Unit", ph: "From entity" }, + { k: "decimals", t: "number", l: "Decimals", d: "" }, + { k: "warn", t: "number", l: "Warn above", d: "" }, + { k: "crit", t: "number", l: "Critical above", d: "" }, + { k: "icon", t: "icon", l: "Icon" }, + ], + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity); + const v = Number(s.state); + const lvl = isFinite(v) && has(c.crit) && v > Number(c.crit) ? "crit" : isFinite(v) && has(c.warn) && v > Number(c.warn) ? "warn" : ""; + const unit = c.unit || s.attributes.unit_of_measurement || ""; + return ``; + }, + }; + + function arc(cx, cy, r, a0, a1) { + const p = (a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)]; + const [x0, y0] = p(a0), [x1, y1] = p(a1); + return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; + } + + W.gauge = { + label: "Gauge", icon: "gauge", size: [3, 3], + fields: [ + { k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number"] }, + { k: "name", t: "text", l: "Name", ph: "Entity name" }, + { k: "min", t: "number", l: "Minimum", d: 0 }, + { k: "max", t: "number", l: "Maximum", d: 100 }, + { k: "green", t: "number", l: "Green from", d: "" }, + { k: "yellow", t: "number", l: "Yellow from", d: "" }, + { k: "red", t: "number", l: "Red from", d: "" }, + ], + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity); + const v = Number(s.state), min = Number(c.min) || 0, max = Number(c.max) || 100; + const frac = isFinite(v) ? Math.max(0, Math.min(1, (v - min) / (max - min || 1))) : 0; + let color = "var(--d-text)"; + if (isFinite(v)) { + if (has(c.red) && v >= Number(c.red)) color = "var(--crit)"; + else if (has(c.yellow) && v >= Number(c.yellow)) color = "var(--warn)"; + else if (has(c.green) && v >= Number(c.green)) color = "var(--ok)"; + } + const a0 = Math.PI * 0.75, span = Math.PI * 1.5; + const unit = s.attributes.unit_of_measurement || ""; + return ``; + }, + }; + + W.light = { + label: "Light", icon: "bulb", size: [3, 2], + fields: [ + { k: "entity", t: "entity", l: "Light", domains: ["light"] }, + { k: "name", t: "text", l: "Name", ph: "Entity name" }, + ], + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity, "a light"); + const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0; + const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff"); + const name = friendly(s, c.name); + return `
+ + ${dimmable ? `` : ""}
`; + }, + lit: (c, { ha }) => litKind(ha.states[c.entity]), + lamp: (c, { ha }) => lampColor(ha.states[c.entity]), + }; + + W.climate = { + label: "Thermostat", icon: "thermometer", size: [3, 3], + fields: [ + { k: "entity", t: "entity", l: "Thermostat", domains: ["climate"] }, + { k: "name", t: "text", l: "Name", ph: "Entity name" }, + { k: "step", t: "number", l: "Step", d: 0.5 }, + ], + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity, "a thermostat"); + const a = s.attributes, step = Number(c.step) || 0.5, e = esc(c.entity); + return `
+
${esc(friendly(s, c.name))} · ${t("Now")} ${esc(a.current_temperature ?? "–")}°
+
+ +
${esc(a.temperature ?? "–")}°
+ +
+
${(a.hvac_modes || []).map((m) => ``).join("")}
`; + }, + }; + + W.graph = { + label: "History graph", icon: "chart", size: [6, 3], + fields: [ + { k: "title", t: "text", l: "Title", d: "" }, + { k: "entities", t: "entities", l: "Sensors", domains: ["sensor", "input_number", "number"] }, + { k: "hours", t: "number", l: "Hours to show", d: 24 }, + { k: "fill", t: "check", l: "Fill under the line", d: true }, + ], + entities: (c) => c.entities || [], + render(c) { + if (!c.entities?.length) return `
${t("Add one or more sensors in the inspector.")}
`; + return `
${title(c.title)}
${t("Loading history…")}
`; + }, + async after(el, c, ctx) { + if (!c.entities?.length) return; + const hours = Number(c.hours) || 24; + const key = c.entities.join(",") + "|" + hours; + const cache = ctx.cache; + if (cache.key !== key || Date.now() - cache.at > 10 * 60 * 1000) { + cache.key = key; cache.at = Date.now(); + cache.loading = ctx.ha.history(c.entities, hours).catch(() => ({})).then((s) => (cache.series = s)); + } + // a re-render while the fetch is in flight must wait for the same request + await cache.loading; + if (!el.isConnected) return; + for (const eid of c.entities) { + const s = ctx.ha.states[eid]; + if (!s || !cache.series[eid]) continue; + const pts = cache.series[eid], last = pts[pts.length - 1]; + const t = new Date(s.last_updated).getTime(); + if (!last || t > last[0]) pts.push([t, Number(s.state)]); + const cutoff = Date.now() - hours * 3600e3; + while (pts.length > 2 && pts[0][0] < cutoff) pts.shift(); + } + drawGraph(el, c, ctx); + }, + }; + + // Series differ by dash pattern as well as color, so color is never the only cue. + const SERIES = [["var(--d-accent)", ""], ["var(--warn)", "6 4"], ["var(--ok)", "2 3"], ["var(--crit)", "10 3 2 3"]]; + + function drawGraph(el, c, ctx) { + const plot = el.querySelector(".plot"), legend = el.querySelector(".legend"); + if (!plot) return; + const W_ = plot.clientWidth, H = plot.clientHeight; + const series = c.entities.map((eid) => ({ eid, pts: (ctx.cache.series || {})[eid] || [] })).filter((s) => s.pts.length); + legend.innerHTML = c.entities.map((eid, i) => { + const s = ctx.ha.states[eid], [col, dash] = SERIES[i % SERIES.length]; + return `${esc(friendly(s))} ${esc(s ? fmtState(s) : "–")}`; + }).join(""); + if (!series.length || W_ < 10 || H < 10) { plot.innerHTML = `
${t("No numeric history for this period.")}
`; return; } + const hours = Number(c.hours) || 24; + const t1 = Date.now(), t0 = t1 - hours * 3600e3; + let lo = Infinity, hi = -Infinity; + series.forEach((s) => s.pts.forEach(([, v]) => { if (isFinite(v)) { lo = Math.min(lo, v); hi = Math.max(hi, v); } })); + if (!isFinite(lo)) { plot.innerHTML = `
${t("No numeric history for this period.")}
`; return; } + if (hi - lo < 1e-6) { hi += 1; lo -= 1; } + const pad = (hi - lo) * 0.1; lo -= pad; hi += pad; + const L = 36, B = 16; + const x = (t) => L + ((t - t0) / (t1 - t0)) * (W_ - L); + const y = (v) => (1 - (v - lo) / (hi - lo)) * (H - B); + const paths = series.map((s) => { + const [col, dash] = SERIES[c.entities.indexOf(s.eid) % SERIES.length]; + const pts = s.pts.filter(([, v]) => isFinite(v)); + if (!pts.length) return ""; + let d = ""; + pts.forEach(([t, v], j) => { d += `${j ? "L" : "M"}${x(Math.max(t, t0)).toFixed(1)} ${y(v).toFixed(1)}`; }); + d += `L${x(t1).toFixed(1)} ${y(pts[pts.length - 1][1]).toFixed(1)}`; + const area = c.fill && series.length === 1 ? `` : ""; + return `${area}`; + }).join(""); + const ticks = [0, 0.5, 1].map((f) => lo + (hi - lo) * f); + plot.innerHTML = ` + ${ticks.map((v) => `${fmtNum(v)}`).join("")} + ${paths} + ${t("−{0} h", hours)}${t("Now")}`; + } + + W.clock = { + label: "Clock", icon: "clock", size: [3, 2], + fields: [ + { k: "h24", t: "check", l: "24-hour time", d: true }, + { k: "seconds", t: "check", l: "Show seconds", d: false }, + { k: "date", t: "check", l: "Show date", d: true }, + ], + render: (c) => `
${clockHTML(c.h24, c.seconds, c.date)}
`, + }; + + function clockHTML(h24, sec, date) { + const d = new Date(); + const time = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit", second: sec ? "2-digit" : undefined, hour12: !h24 }); + return `
${time}
${date ? `
${d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" })}
` : ""}`; + } + + W.weather = { + label: "Weather", icon: "cloud-sun", size: [4, 2], + fields: [{ k: "entity", t: "entity", l: "Weather", domains: ["weather"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }], + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity, "a weather entity"); + const a = s.attributes; + return ``; + }, + }; + + W.list = { + label: "Entity list", icon: "list", size: [4, 4], + fields: [{ k: "title", t: "text", l: "Title", d: "Devices" }, { k: "entities", t: "entities", l: "Entities" }], + entities: (c) => c.entities || [], + render(c, { ha }) { + if (!c.entities?.length) return `${title(c.title)}
${t("Add entities in the inspector.")}
`; + const rows = c.entities.map((eid) => { + const s = ha.states[eid]; + if (!s) return `
${Icons.svg("pulse")}${esc(eid)}
`; + const sw = ["light", "switch", "fan", "input_boolean", "automation"].includes(domainOf(eid)); + // Switches belong in list rows, so this is the one place we draw them. + return sw + ? `` + : ``; + }).join(""); + return `${title(c.title)}
${rows}
`; + }, + }; + + W.button = { + label: "Button", icon: "button", size: [2, 2], + fields: [ + { k: "label", t: "text", l: "Label", d: "Run" }, + { k: "icon", t: "icon", l: "Icon", d: "sparkles" }, + { k: "entity", t: "entity", l: "Target entity (optional)" }, + { k: "service", t: "text", l: "Action (e.g. scene.turn_on)", d: "" }, + { k: "data", t: "json", l: "Action data (JSON)", d: "" }, + ], + render: (c) => ``, + }; + + W.media = { + label: "Media player", icon: "music", size: [4, 2], + fields: [{ k: "entity", t: "entity", l: "Media player", domains: ["media_player"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }], + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return missing(c.entity, "a media player"); + const a = s.attributes, e = esc(c.entity), playing = s.state === "playing"; + return `
${esc(friendly(s, c.name))}
+
${esc(a.media_title || st(s.state))}
${esc(a.media_artist || "")}
+
+ + + + ${a.volume_level != null ? `` : ""} +
`; + }, + lit: (c, { ha }) => (ha.states[c.entity]?.state === "playing" ? "on" : false), + }; + + W.camera = { + label: "Camera", icon: "camera", size: [6, 4], flush: true, + fields: [{ k: "entity", t: "entity", l: "Camera", domains: ["camera"] }, { k: "refresh", t: "number", l: "Refresh every (seconds)", d: 10 }, { k: "showName", t: "check", l: "Show name", d: true }], + render(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return `
${missing(c.entity, "a camera")}
`; + return ` + ${c.showName ? `
${esc(friendly(s))}
` : ""}`; + }, + }; + + // ---------------------------------------------------------------- car + const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase())); + + W.car = { + label: "Car", icon: "car", size: [6, 3], + fields: [ + { k: "name", t: "text", l: "Name", d: "My car" }, + { k: "image", t: "text", l: "Car image URL (optional)", ph: "https://… or /local/car.png" }, + { k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" }, + { k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] }, + { k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] }, + { k: "charging", t: "entity", l: "Charging", domains: ["binary_sensor", "sensor", "switch"] }, + { k: "power", t: "entity", l: "Charging power", domains: ["sensor"] }, + { k: "plug", t: "entity", l: "Plugged in", domains: ["binary_sensor", "sensor"] }, + { k: "limit", t: "entity", l: "Charge limit (%)", domains: ["number", "sensor", "input_number"] }, + { k: "lock", t: "entity", l: "Doors", domains: ["lock"] }, + { k: "climate", t: "entity", l: "Climate", domains: ["climate", "switch", "input_boolean"] }, + { k: "location", t: "entity", l: "Location", domains: ["device_tracker", "person", "sensor"] }, + { k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] }, + ], + entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean), + render(c, { ha }) { + const st = (k) => (c[k] ? ha.states[c[k]] : null); + const level = st("level"), range = st("range"), lock = st("lock"), clim = st("climate"), loc = st("location"); + if (!c.level && !c.range && !c.lock) return `
${t("Set the car’s level, range or lock entity in the inspector.")}
`; + const pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null; + const power = st("power"), plug = st("plug"), limit = st("limit"); + const charging = truthy(st("charging")) || (!c.charging && power && Number(power.state) > 0); + const plugged = charging || truthy(plug); + const low = pct != null && pct <= 20; + + let status; + if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`; + else if (plugged) status = t("Plugged in"); + else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state); + else if (lock) status = lock.state === "locked" ? t("Locked") : t("Unlocked"); + else status = ""; + + const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null; + const bar = pct == null ? "" : ` +
+ + ${limPct != null && c.kind !== "fuel" ? `` : ""} +
`; + const actions = []; + if (lock) { + const locked = lock.state === "locked"; + actions.push(``); + } + if (clim) { + const on = isOn(clim) || clim.state === "on"; + actions.push(``); + } + if (loc) actions.push(``); + const odo = st("odometer"); + + return `
+ ${c.image ? `` : ""} +
${esc(c.name || t("Car"))}
${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}
+
+ ${pct != null ? `${pct}%` : ""} + ${range ? `${esc(fmtState(range))} ${t("range")}` : ""} + ${low ? `${t("Low")}` : ""} +
+ ${bar} + ${actions.length || odo ? `
${actions.join("")}${odo ? `${esc(fmtState(odo))}` : ""}
` : ""} +
`; + }, + }; + + W.text = { + label: "Text", icon: "text", size: [4, 2], + fields: [ + { k: "text", t: "textarea", l: "Text — use {{ sensor.x }} for live values", d: "Inside it’s {{ sensor.living_room_temperature }}." }, + { k: "size", t: "number", l: "Font size (px)", d: 17 }, + ], + entities: (c) => [...String(c.text || "").matchAll(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g)].map((m) => m[1]), + render(c, { ha }) { + const html = esc(c.text).replace(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g, (_, eid) => `${esc(fmtState(ha.states[eid]))}`); + return `
${html}
`; + }, + }; + + // ---------------------------------------------------------------- photos + // One image, a slideshow of URLs, or a slideshow of Immich albums (proxied by the server). + const urlList = (c) => String(c.urls ?? c.url ?? "").split(/\s*\n\s*/).map((u) => u.trim()).filter(Boolean); + + W.image = { + label: "Photos", icon: "image", size: [4, 3], flush: true, + fields: [ + { k: "source", t: "seg", l: "Source", o: ["links", "immich"], d: "links" }, + { k: "urls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.source !== "immich" }, + { k: "albums", t: "albums", l: "Immich albums", when: (c) => c.source === "immich" }, + { k: "interval", t: "number", l: "Change photo every (seconds)", d: 20 }, + { k: "shuffle", t: "check", l: "Shuffle", d: true }, + { k: "fit", t: "seg", l: "Fit", o: ["cover", "contain"], d: "cover" }, + { k: "showDate", t: "check", l: "Show when the photo was taken", d: false, when: (c) => c.source === "immich" }, + { k: "alt", t: "text", l: "Description (for screen readers)" }, + ], + render(c) { + const empty = c.source === "immich" ? (c.albums?.length ? "" : t("Choose one or more Immich albums in the inspector.")) : urlList(c).length ? "" : t("Add image URLs in the inspector."); + if (empty) return `
${empty}
`; + return ``; + }, + async after(el, c, ctx) { + const cache = ctx.cache; + clearTimeout(cache.timer); + const box = el.querySelector(".photos"); + if (!box) return; + const msg = box.querySelector(".ph-msg"); + const key = JSON.stringify([c.source, c.urls ?? c.url, c.albums, c.shuffle]); + if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) { + cache.key = key; cache.at = Date.now(); cache.pos = 0; + cache.loading = (async () => { + if (c.source !== "immich") return urlList(c).map((src) => ({ src })); + const lists = await Promise.all((c.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`))); + return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date })); + })().then((items) => { + if (c.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; } + cache.items = items; cache.error = null; + }).catch((e) => { cache.items = []; cache.error = e.message; }); + } + await cache.loading; + if (!el.isConnected) return; + if (cache.error) { msg.innerHTML = `
${t("Couldn’t load photos. {0}", esc(cache.error))}
`; return; } + if (!cache.items.length) { msg.innerHTML = `
${t("These albums have no photos yet.")}
`; return; } + + const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date"); + let front = 0; + const show = () => { + if (!el.isConnected) return; + const item = cache.items[cache.pos % cache.items.length]; + cache.pos++; + const next = layers[1 - front]; + next.onload = () => { + next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front; + msg.innerHTML = ""; + dateEl.textContent = c.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : ""; + }; + next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); }; + next.src = item.src; + if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(c.interval) || 20) * 1000); + }; + show(); + }, + }; + + // ---------------------------------------------------------------- calendar + const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"]; + const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const parseWhen = (v) => (typeof v === "string" && !v.includes("T") ? new Date(`${v}T00:00:00`) : new Date(v?.dateTime || v?.date || v)); + const hm = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); + + W.calendar = { + label: "Calendar", icon: "calendar", size: [4, 4], + fields: [ + { k: "title", t: "text", l: "Title", d: "Upcoming" }, + { k: "entities", t: "entities", l: "Calendars", domains: ["calendar"] }, + { k: "days", t: "number", l: "Days ahead", d: 7 }, + { k: "max", t: "number", l: "Most events to show", d: 12 }, + { k: "showCalendar", t: "check", l: "Show which calendar", d: true }, + ], + entities: (c) => c.entities || [], + render(c) { + if (!c.entities?.length) return `${title(c.title)}
${t("Add one or more calendars in the inspector.")}
`; + return `${title(c.title)}
${t("Loading events…")}
`; + }, + async after(el, c, ctx) { + if (!c.entities?.length) return; + const days = Math.max(1, Number(c.days) || 7); + const key = c.entities.join(",") + "|" + days; + const cache = ctx.cache; + if (cache.key !== key || Date.now() - cache.at > 5 * 60 * 1000) { + cache.key = key; cache.at = Date.now(); + const start = new Date(); start.setHours(0, 0, 0, 0); + const end = new Date(start.getTime() + days * 86400e3); + cache.loading = ctx.ha.calendarEvents(c.entities, start, end) + .then((res) => { cache.res = res || {}; cache.error = null; }) + .catch((e) => { cache.res = {}; cache.error = e.message; }); + } + await cache.loading; + const list = el.querySelector(".cal"); + if (!el.isConnected || !list) return; + if (cache.error) { list.innerHTML = `
${t("Couldn’t load events. {0}", esc(cache.error))}
`; return; } + + const now = new Date(); + const events = []; + c.entities.forEach((eid, i) => { + for (const ev of cache.res[eid]?.events || []) { + const allDay = typeof ev.start === "string" ? !ev.start.includes("T") : !!ev.start?.date; + const s = parseWhen(ev.start), e = parseWhen(ev.end); + if (e < now) continue; + events.push({ ...ev, s, e, allDay, cal: friendly(ctx.ha.states[eid]), color: CAL_COLORS[i % CAL_COLORS.length] }); + } + }); + events.sort((a, b) => a.s - b.s || b.allDay - a.allDay); + const shown = events.slice(0, Math.max(1, Number(c.max) || 12)); + if (!shown.length) { list.innerHTML = `
${t("Nothing planned for the next {0} days.", days)}
`; return; } + + const today = new Date(); today.setHours(0, 0, 0, 0); + const label = (d) => { + const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400e3); + return diff <= 0 ? t("Today") : diff === 1 ? t("Tomorrow") : d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" }); + }; + let html = "", lastDay = ""; + const multi = c.entities.length > 1 && c.showCalendar; + for (const ev of shown) { + const day = ev.s < today ? today : ev.s; + if (dayKey(day) !== lastDay) { lastDay = dayKey(day); html += `
${esc(label(day))}
`; } + const when = ev.allDay ? t("All day") : `${hm(ev.s)}–${hm(ev.e)}`; + const live = !ev.allDay && ev.s <= now && ev.e > now; + html += `
+ ${esc(when)} + ${esc(ev.summary || t("Busy"))}${ev.location || multi ? `${esc([multi ? ev.cal : "", ev.location].filter(Boolean).join(" · "))}` : ""} + ${live ? `${t("Now")}` : ""}
`; + } + list.innerHTML = html; + }, + }; + + W.iframe = { + label: "Web page", icon: "globe", size: [6, 4], flush: true, + fields: [{ k: "url", t: "text", l: "URL" }], + render: (c) => (c.url ? `` : `
${t("Add a URL in the inspector.")}
`), + }; + + // ---------------------------------------------------------------- waste (Afvalwijzer) + // Reads the per-type sensors of the Afvalwijzer integration: state is the next pickup + // date (ISO, or a timestamp), with days_until_collection_date and is_collection_date_*. + const WASTE_TYPES = { + gft: ["Organic", "#4CAF50"], papier: ["Paper", "#2196F3"], pmd: ["PMD", "#800000"], plastic: ["Plastic", "#FF9800"], + restafval: ["Residual waste", "#9E9E9E"], restafvalzakken: ["Residual waste bags", "#9E9E9E"], restwagen: ["Residual waste", "#9E9E9E"], + textiel: ["Textile", "#00695C"], grofvuil: ["Bulky waste", "#795548"], kca: ["Hazardous waste", "#F44336"], luiers: ["Nappies", "#FFA000"], + glas: ["Glass", "#009688"], snoeiafval: ["Pruning waste", "#2E7D32"], tuinafval: ["Garden waste", "#2E7D32"], kerstbomen: ["Christmas trees", "#1B5E20"], + best_tas: ["BEST bag", "#6D4C41"], milieubus: ["Hazardous waste van", "#F44336"], grip: ["GRIP", "#607D8B"], maas: ["Maas", "#607D8B"], + }; + const WASTE_KEYS = Object.keys(WASTE_TYPES).sort((a, b) => b.length - a.length); + const isWasteSensor = (s) => !!s && s.entity_id.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in s.attributes; + const wasteKey = (eid) => WASTE_KEYS.find((k) => eid.endsWith(`_${k}`)) || eid.split("_").pop(); + + function wasteItems(c, ha) { + const ids = c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => isWasteSensor(ha.states[e])); + const today = new Date(); today.setHours(0, 0, 0, 0); + const out = []; + for (const eid of ids) { + const s = ha.states[eid]; + if (!s) continue; + const raw = s.state; + const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw); + if (isNaN(date)) continue; // "geen" or unavailable: no pickup planned + date.setHours(0, 0, 0, 0); + const days = s.attributes.days_until_collection_date ?? Math.round((date - today) / 86400e3); + if (days < 0) continue; + const key = wasteKey(eid), known = WASTE_TYPES[key]; + out.push({ eid, key, days, date, name: known ? t(known[0]) : friendly(s).replace(/^afvalwijzer\s*/i, "") || cap(key), color: known?.[1] || "var(--d-text-2)" }); + } + return out.sort((a, b) => a.days - b.days || a.name.localeCompare(b.name)); + } + const whenLabel = (d) => (d === 0 ? t("Today") : d === 1 ? t("Tomorrow") : d === 2 ? t("Day after tomorrow") : t("In {0} days", d)); + const shortDate = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" }); + + W.waste = { + label: "Waste collection", icon: "bin", size: [4, 4], + fields: [ + { k: "title", t: "text", l: "Title", d: "Waste collection" }, + { k: "layout", t: "seg", l: "Layout", o: ["next", "list"], d: "next" }, + { k: "entities", t: "entities", l: "Waste sensors (leave empty to find them automatically)", domains: ["sensor"] }, + { k: "max", t: "number", l: "Most pickups to show", d: 6 }, + { k: "within", t: "number", l: "Only pickups within (days)", d: 31 }, + ], + // With automatic discovery every Afvalwijzer sensor matters, so listen to all of them. + entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("sensor.afvalwijzer_"))), + render(c, { ha }) { + const within = Number(c.within) || 31; + const items = wasteItems(c, ha).filter((i) => i.days <= within).slice(0, Math.max(1, Number(c.max) || 6)); + if (!items.length) { + const any = c.entities?.length || Object.values(ha.states).some(isWasteSensor); + return `${title(c.title)}
${any ? t("No pickups in the next {0} days.", within) : t("No Afvalwijzer sensors found. Install the Afvalwijzer integration, or pick the sensors in the inspector.")}
`; + } + const dot = (i) => ``; + let head = "", rest = items; + if (c.layout !== "list") { + const first = items.filter((i) => i.days === items[0].days); + rest = items.slice(first.length); + const soon = first[0].days <= 1; + head = `
+
${first.map(dot).join("")}
+
${t("Next pickup")} + ${esc(whenLabel(first[0].days))} + ${esc(first.map((i) => i.name).join(" · "))} + ${first[0].days === 1 ? `${t("Put the bin out tonight")}` : ""}
`; + } + const rows = rest.map((i) => ``).join(""); + return `${title(c.title)}${head}${rows ? `
${rows}
` : ""}`; + }, + }; + + window.Widgets = W; + window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap }; +})(); diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..c95adb1 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: Dashboard Studio Apps +url: https://git.melvanveen.nl/veenm/ha-dashboard-studio +maintainer: veenm