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 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
})();
|
||||
@@ -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 `<svg class="i ${FILLED.has(name) ? "f" : ""} ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${P[name]}"/></svg>`;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)">
|
||||
<title>Dashboard Studio</title>
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="topbar" role="toolbar" aria-label="Dashboard">
|
||||
<div class="tb-group">
|
||||
<label class="picker">
|
||||
<span class="sr-only" data-i18n>Dashboard</span>
|
||||
<select id="dash-select"></select>
|
||||
<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</label>
|
||||
<button id="btn-new" class="btn icon" aria-label="New dashboard" title="New dashboard"></button>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<span id="conn" class="conn" role="status" data-i18n>Offline</span>
|
||||
<span id="save-status" class="save-status edit-only" role="status"></span>
|
||||
<span class="edit-only tb-group">
|
||||
<button id="btn-undo" class="btn icon" aria-label="Undo" title="Undo (⌘Z)"></button>
|
||||
<button id="btn-redo" class="btn icon" aria-label="Redo" title="Redo (⇧⌘Z)"></button>
|
||||
<button id="btn-menu" class="btn icon" aria-label="More actions" aria-haspopup="menu" aria-expanded="false" title="More actions"></button>
|
||||
</span>
|
||||
<button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button>
|
||||
<button id="btn-edit" class="btn primary" data-i18n>Edit</button>
|
||||
</div>
|
||||
<div id="menu" class="menu" role="menu" hidden>
|
||||
<button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button>
|
||||
<button role="menuitem" data-menu="duplicate" data-icon="copy" data-i18n>Duplicate</button>
|
||||
<button role="menuitem" data-menu="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
|
||||
<hr>
|
||||
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
|
||||
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
|
||||
<hr>
|
||||
<button role="menuitem" data-menu="delete" data-icon="trash" class="danger" data-i18n>Delete dashboard…</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="workspace">
|
||||
<aside id="palette" class="edit-only" aria-label="Widgets">
|
||||
<div class="panel-title" data-i18n>Add widget</div>
|
||||
<div id="palette-list"></div>
|
||||
<p class="hint" data-i18n>Click to add. Drag to move, drag the corner to resize. Arrow keys move the selected widget, Shift+arrows resize, ⌘D duplicates, Delete removes.</p>
|
||||
</aside>
|
||||
|
||||
<main id="stage">
|
||||
<nav id="tabnav" aria-label="Tabs" hidden></nav>
|
||||
<div id="canvas"><div id="grid"></div></div>
|
||||
</main>
|
||||
|
||||
<aside id="inspector" class="edit-only" aria-label="Inspector"></aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="saver" hidden aria-hidden="true"><div class="time num"></div><div class="date"></div></div>
|
||||
|
||||
<div id="modal" class="modal" hidden><div class="modal-card" role="dialog" aria-modal="true"></div></div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<input type="file" id="import-file" accept="application/json,.json" hidden>
|
||||
|
||||
<script src="static/i18n.js"></script>
|
||||
<script src="static/icons.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 <button>s carrying data-act / data-e, handled by app.js.
|
||||
*/
|
||||
(function () {
|
||||
const Icons = window.Icons;
|
||||
const { t, state: st, locale: LOCALE } = window.I18n;
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const domainOf = (eid) => (eid || "").split(".")[0];
|
||||
|
||||
const ON_STATES = new Set(["on", "open", "opening", "unlocked", "playing", "home", "heat", "cool", "auto", "heat_cool", "dry", "fan_only", "cleaning"]);
|
||||
const TOGGLE_DOMAINS = new Set(["light", "switch", "fan", "input_boolean", "automation", "cover", "lock", "media_player", "scene", "script", "button", "input_button", "siren", "humidifier", "vacuum", "climate"]);
|
||||
const STATELESS = new Set(["scene", "script", "button", "input_button"]);
|
||||
|
||||
const isOn = (s) => !!s && ON_STATES.has(s.state);
|
||||
const friendly = (s, override) => override || s?.attributes?.friendly_name || s?.entity_id || "";
|
||||
|
||||
// ------------------------------------------------------------ icons
|
||||
const DOMAIN_ICON = {
|
||||
light: "bulb", switch: "plug", fan: "fan", input_boolean: "toggle", automation: "robot", cover: "window",
|
||||
media_player: "speaker", climate: "thermometer", scene: "sparkles", script: "script", camera: "camera",
|
||||
person: "person", device_tracker: "pin", vacuum: "vacuum", button: "button", input_button: "button",
|
||||
sun: "sun", alarm_control_panel: "shield", weather: "cloud-sun", input_number: "number", number: "number", counter: "number",
|
||||
};
|
||||
const CLASS_ICON = {
|
||||
temperature: "thermometer", humidity: "droplet", moisture: "droplet", power: "bolt", energy: "bolt", voltage: "bolt",
|
||||
current: "bolt", battery: "battery", illuminance: "sun", pressure: "gauge", gas: "flame", water: "droplet",
|
||||
distance: "road", speed: "wind", wind_speed: "wind", co2: "pulse", pm25: "fog", aqi: "fog",
|
||||
};
|
||||
const BINARY_ICON = { door: ["door", "door-open"], garage_door: ["garage", "garage"], window: ["window", "window"], opening: ["door", "door-open"], motion: ["person", "person"], occupancy: ["person", "person"], presence: ["home", "home"], smoke: ["flame", "flame"], moisture: ["droplet", "droplet"], plug: ["cable", "cable"], battery_charging: ["battery", "bolt"], lock: ["lock", "unlock"] };
|
||||
const WX = { "clear-night": "moon", cloudy: "cloud", exceptional: "shield", fog: "fog", hail: "snow", lightning: "storm", "lightning-rainy": "storm", partlycloudy: "cloud-sun", pouring: "rain", rainy: "rain", snowy: "snow", "snowy-rainy": "snow", sunny: "sun", windy: "wind", "windy-variant": "wind" };
|
||||
|
||||
function iconName(s) {
|
||||
if (!s) return "pulse";
|
||||
const d = domainOf(s.entity_id), dc = s.attributes.device_class;
|
||||
if (d === "binary_sensor") return (BINARY_ICON[dc] || ["pulse", "pulse"])[isOn(s) ? 1 : 0];
|
||||
if (d === "sensor") return CLASS_ICON[dc] || "pulse";
|
||||
if (d === "lock") return s.state === "locked" ? "lock" : "unlock";
|
||||
if (d === "cover" && dc === "garage") return "garage";
|
||||
if (d === "weather") return WX[s.state] || "cloud";
|
||||
if (d === "media_player" && s.state === "playing") return "music";
|
||||
return DOMAIN_ICON[d] || "pulse";
|
||||
}
|
||||
/** An override can be an icon name from the set or any text, such as an emoji. */
|
||||
function icon(s, override) {
|
||||
if (override) return Icons.has(override) ? Icons.svg(override) : `<span class="glyph">${esc(override)}</span>`;
|
||||
return Icons.svg(iconName(s));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ formatting
|
||||
const cap = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : "").replace(/_/g, " ");
|
||||
const BINARY_LABELS = { door: ["Closed", "Open"], window: ["Closed", "Open"], garage_door: ["Closed", "Open"], opening: ["Closed", "Open"], motion: ["Clear", "Motion"], occupancy: ["Clear", "Occupied"], moisture: ["Dry", "Wet"], smoke: ["Clear", "Smoke"], presence: ["Away", "Home"], plug: ["Unplugged", "Plugged in"], battery_charging: ["Not charging", "Charging"] };
|
||||
|
||||
function fmtNum(v, decimals) {
|
||||
const n = Number(v);
|
||||
if (!isFinite(n)) return v;
|
||||
if (decimals !== "" && decimals != null && !isNaN(decimals)) {
|
||||
const d = Math.max(0, Math.min(6, Number(decimals)));
|
||||
return n.toLocaleString(LOCALE, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
}
|
||||
return n.toLocaleString(LOCALE, { maximumFractionDigits: Math.abs(n) >= 100 ? 0 : 1 });
|
||||
}
|
||||
|
||||
function fmtState(s) {
|
||||
if (!s) return t("Unavailable");
|
||||
const d = domainOf(s.entity_id), a = s.attributes;
|
||||
if (s.state === "unavailable" || s.state === "unknown") return st(s.state);
|
||||
if (d === "binary_sensor") return t((BINARY_LABELS[a.device_class] || ["Off", "On"])[isOn(s) ? 1 : 0]);
|
||||
if (d === "light") return s.state === "on" ? (a.brightness ? `${Math.round(a.brightness / 2.55)}%` : t("On")) : t("Off");
|
||||
if (d === "climate") return `${st(s.state)}${a.temperature != null ? " · " + a.temperature + "°" : ""}`;
|
||||
if (d === "scene") return t("Scene");
|
||||
if (d === "script") return s.state === "on" ? t("Running") : t("Script");
|
||||
if (d === "media_player" && a.media_title) return `${st(s.state)} · ${a.media_title}`;
|
||||
if (d === "weather") return `${st(s.state)} · ${a.temperature}°`;
|
||||
if (d === "device_tracker" || d === "person") return s.state === "home" ? t("Home") : s.state === "not_home" ? t("Away") : st(s.state);
|
||||
if (a.unit_of_measurement && !isNaN(Number(s.state))) return `${fmtNum(s.state)} ${a.unit_of_measurement}`;
|
||||
return st(s.state);
|
||||
}
|
||||
|
||||
const relTime = (iso) => {
|
||||
const sec = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (sec < 60) return t("just now");
|
||||
if (sec < 3600) return t("{0} min ago", Math.floor(sec / 60));
|
||||
if (sec < 86400) return t("{0} h ago", Math.floor(sec / 3600));
|
||||
return t("{0} d ago", Math.floor(sec / 86400));
|
||||
};
|
||||
|
||||
const missing = (c, what = "an entity") => `<div class="empty">${c ? t("Can’t find {0}.", esc(c)) + "<br>" + t("Check the entity ID in the inspector.") : t("Choose {0} in the inspector.", t(what))}</div>`;
|
||||
const title = (txt) => (txt ? `<div class="wt">${esc(txt)}</div>` : "");
|
||||
const has = (x) => x !== "" && x != null && isFinite(Number(x));
|
||||
|
||||
function lampColor(s) {
|
||||
const rgb = s?.attributes?.rgb_color;
|
||||
return s?.state === "on" && Array.isArray(rgb) ? `rgb(${rgb.join(",")})` : null;
|
||||
}
|
||||
const litKind = (s) => (!isOn(s) ? false : domainOf(s.entity_id) === "light" ? "lamp" : "on");
|
||||
|
||||
// ------------------------------------------------------------ widgets
|
||||
const W = {};
|
||||
|
||||
W.heading = {
|
||||
label: "Heading", icon: "heading", size: [6, 1], transparent: true,
|
||||
fields: [
|
||||
{ k: "text", t: "text", l: "Text", d: "Welcome home" },
|
||||
{ k: "subtitle", t: "text", l: "Subtitle", d: "" },
|
||||
{ k: "size", t: "number", l: "Font size (px)", d: 30 },
|
||||
{ k: "align", t: "seg", l: "Alignment", o: ["left", "center", "right"], d: "left" },
|
||||
],
|
||||
render: (c) => `<div class="heading" style="text-align:${esc(c.align)}">
|
||||
<h2 class="h-main" style="font-size:${Number(c.size) || 30}px">${esc(c.text)}</h2>
|
||||
${c.subtitle ? `<div class="wt wrap">${esc(c.subtitle)}</div>` : ""}</div>`,
|
||||
};
|
||||
|
||||
W.entity = {
|
||||
label: "Entity tile", icon: "tile", size: [3, 2],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Entity" },
|
||||
{ k: "name", t: "text", l: "Name" , ph: "Entity name" },
|
||||
{ k: "icon", t: "icon", l: "Icon" },
|
||||
{ k: "tap", t: "seg", l: "Tap action", o: ["auto", "toggle", "more-info", "none"], d: "auto" },
|
||||
{ k: "showChanged", t: "check", l: "Show when it last changed", d: false },
|
||||
],
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity);
|
||||
const d = domainOf(c.entity);
|
||||
const tap = c.tap === "auto" ? (TOGGLE_DOMAINS.has(d) ? "toggle" : "more-info") : c.tap;
|
||||
const name = friendly(s, c.name), state = fmtState(s) + (c.showChanged ? " · " + relTime(s.last_changed) : "");
|
||||
const inner = `<span class="badge">${icon(s, c.icon)}</span>
|
||||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(state)}</span></span>`;
|
||||
if (tap === "none") return `<div class="tile">${inner}</div>`;
|
||||
const pressed = tap === "toggle" && !STATELESS.has(d) ? ` aria-pressed="${isOn(s)}"` : "";
|
||||
return `<button class="tile wbtn" data-act="${tap === "toggle" ? "toggle" : "more"}" data-e="${esc(c.entity)}"${pressed}>${inner}</button>`;
|
||||
},
|
||||
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 `<button class="value wbtn ${lvl}" data-act="more" data-e="${esc(c.entity)}">
|
||||
<span class="value-top"><span class="wt">${esc(friendly(s, c.name))}</span><span class="value-ico">${icon(s, c.icon)}</span></span>
|
||||
<span class="big num">${esc(isFinite(v) ? fmtNum(s.state, c.decimals) : cap(s.state))}<span class="unit">${esc(unit)}</span></span>
|
||||
${lvl ? `<span class="level">${lvl === "crit" ? t("Critical") : t("High")}</span>` : ""}</button>`;
|
||||
},
|
||||
};
|
||||
|
||||
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 `<button class="gauge wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(friendly(s, c.name))}, ${esc(fmtState(s))}">
|
||||
<svg viewBox="0 0 100 82" aria-hidden="true">
|
||||
<path d="${arc(50, 50, 40, a0, a0 + span)}" stroke="var(--d-fill)" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
${frac > 0.001 ? `<path d="${arc(50, 50, 40, a0, a0 + span * frac)}" stroke="${color}" stroke-width="8" fill="none" stroke-linecap="round"/>` : ""}
|
||||
<text x="50" y="53" text-anchor="middle" class="num" font-size="20" font-weight="700" fill="currentColor">${esc(isFinite(v) ? fmtNum(v) : "–")}</text>
|
||||
<text x="50" y="67" text-anchor="middle" font-size="9" fill="var(--d-text-2)">${esc(unit)}</text>
|
||||
</svg><span class="wt">${esc(friendly(s, c.name))}</span></button>`;
|
||||
},
|
||||
};
|
||||
|
||||
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 `<div class="light">
|
||||
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
|
||||
<span class="badge">${icon(s)}</span>
|
||||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
|
||||
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}</div>`;
|
||||
},
|
||||
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 `<div class="climate">
|
||||
<div class="wt">${esc(friendly(s, c.name))} · ${t("Now")} ${esc(a.current_temperature ?? "–")}°</div>
|
||||
<div class="temps">
|
||||
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${-step}" aria-label="${t("Lower to {0}°", esc((a.temperature ?? 0) - step))}">${Icons.svg("minus")}</button>
|
||||
<div class="target num" aria-live="polite">${esc(a.temperature ?? "–")}°</div>
|
||||
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${step}" aria-label="${t("Raise to {0}°", esc((a.temperature ?? 0) + step))}">${Icons.svg("plus")}</button>
|
||||
</div>
|
||||
<div class="chips" role="group" aria-label="${t("Mode")}">${(a.hvac_modes || []).map((m) => `<button class="chip wbtn" aria-pressed="${m === s.state}" data-act="hvac" data-e="${e}" data-v="${esc(m)}">${esc(st(m))}</button>`).join("")}</div></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
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 `<div class="empty">${t("Add one or more sensors in the inspector.")}</div>`;
|
||||
return `<div class="graph">${title(c.title)}<div class="legend"></div><div class="plot"><div class="empty">${t("Loading history…")}</div></div></div>`;
|
||||
},
|
||||
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 `<span><svg width="18" height="8" aria-hidden="true"><line x1="1" y1="4" x2="17" y2="4" stroke="${col}" stroke-width="2.5" stroke-dasharray="${dash}" stroke-linecap="round"/></svg>${esc(friendly(s))} <b class="num">${esc(s ? fmtState(s) : "–")}</b></span>`;
|
||||
}).join("");
|
||||
if (!series.length || W_ < 10 || H < 10) { plot.innerHTML = `<div class="empty">${t("No numeric history for this period.")}</div>`; 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 = `<div class="empty">${t("No numeric history for this period.")}</div>`; 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 ? `<path d="${d}L${x(t1).toFixed(1)} ${H - B}L${x(Math.max(pts[0][0], t0)).toFixed(1)} ${H - B}Z" fill="${col}" opacity=".12"/>` : "";
|
||||
return `${area}<path d="${d}" fill="none" stroke="${col}" stroke-width="2" stroke-dasharray="${dash}" stroke-linejoin="round" stroke-linecap="round"/>`;
|
||||
}).join("");
|
||||
const ticks = [0, 0.5, 1].map((f) => lo + (hi - lo) * f);
|
||||
plot.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" role="img" aria-label="${t("History for the last {0} hours", hours)}">
|
||||
${ticks.map((v) => `<line x1="${L}" x2="${W_}" y1="${y(v)}" y2="${y(v)}" stroke="var(--d-separator)"/><text class="axis" x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${fmtNum(v)}</text>`).join("")}
|
||||
${paths}
|
||||
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
|
||||
}
|
||||
|
||||
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) => `<div class="clock" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>`,
|
||||
};
|
||||
|
||||
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 `<div class="time num">${time}</div>${date ? `<div class="date">${d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" })}</div>` : ""}`;
|
||||
}
|
||||
|
||||
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 `<button class="weather wbtn" data-act="more" data-e="${esc(c.entity)}"><span class="wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span>
|
||||
<span class="wx-text"><span class="wx-temp num">${esc(a.temperature ?? "–")}°</span><span class="wt">${esc(friendly(s, c.name))} · ${esc(st(s.state))}</span>
|
||||
<span class="wt">${t("Humidity")} ${esc(a.humidity ?? "–")}% · ${t("Wind")} ${esc(a.wind_speed ?? "–")} ${esc(a.wind_speed_unit || "km/h")}</span></span></button>`;
|
||||
},
|
||||
};
|
||||
|
||||
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)}<div class="empty">${t("Add entities in the inspector.")}</div>`;
|
||||
const rows = c.entities.map((eid) => {
|
||||
const s = ha.states[eid];
|
||||
if (!s) return `<div class="list-row"><span class="row-ico">${Icons.svg("pulse")}</span><span class="grow muted">${esc(eid)}</span></div>`;
|
||||
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
|
||||
? `<button class="list-row wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="switch"></span></button>`
|
||||
: `<button class="list-row wbtn" data-act="more" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="st">${esc(fmtState(s))}</span></button>`;
|
||||
}).join("");
|
||||
return `${title(c.title)}<div class="list">${rows}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
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) => `<button class="btn-widget wbtn" data-act="button"><span class="badge">${icon(null, c.icon || "sparkles")}</span><span class="name">${esc(c.label)}</span></button>`,
|
||||
};
|
||||
|
||||
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 `<div class="media"><div class="wt">${esc(friendly(s, c.name))}</div>
|
||||
<div><div class="title">${esc(a.media_title || st(s.state))}</div><div class="wt">${esc(a.media_artist || "")}</div></div>
|
||||
<div class="ctrls">
|
||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
|
||||
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="media_player.media_play_pause" aria-label="${playing ? t("Pause") : t("Play")}">${Icons.svg(playing ? "pause" : "play")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_next_track" aria-label="${t("Next track")}">${Icons.svg("next")}</button>
|
||||
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${t("Volume")}">` : ""}
|
||||
</div></div>`;
|
||||
},
|
||||
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 `<div class="pad">${missing(c.entity, "a camera")}</div>`;
|
||||
return `<button class="cam-btn wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(t("{0} camera", friendly(s)))}"><img class="cam" data-cam="${esc(c.entity)}" data-refresh="${Number(c.refresh) || 10}" src="api/camera/${encodeURIComponent(c.entity)}?t=${Date.now()}" alt=""></button>
|
||||
${c.showName ? `<div class="cam-label">${esc(friendly(s))}</div>` : ""}`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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 `<div class="empty">${t("Set the car’s level, range or lock entity in the inspector.")}</div>`;
|
||||
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 ? "" : `
|
||||
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${c.kind === "fuel" ? t("Fuel level") : t("Battery level")}">
|
||||
<span class="car-fill" style="width:${pct}%"></span>
|
||||
${limPct != null && c.kind !== "fuel" ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
|
||||
</div>`;
|
||||
const actions = [];
|
||||
if (lock) {
|
||||
const locked = lock.state === "locked";
|
||||
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.lock)}" aria-pressed="${!locked}">${Icons.svg(locked ? "lock" : "unlock")}<span>${locked ? t("Locked") : t("Unlocked")}</span></button>`);
|
||||
}
|
||||
if (clim) {
|
||||
const on = isOn(clim) || clim.state === "on";
|
||||
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.climate)}" aria-pressed="${on}">${Icons.svg(on ? "fan" : "snowflake")}<span>${on ? t("Climate on") : t("Climate")}</span></button>`);
|
||||
}
|
||||
if (loc) actions.push(`<button class="car-act wbtn" data-act="more" data-e="${esc(c.location)}">${Icons.svg("pin")}<span>${esc(fmtState(loc))}</span></button>`);
|
||||
const odo = st("odometer");
|
||||
|
||||
return `<div class="car ${c.image ? "has-img" : ""}">
|
||||
${c.image ? `<img class="car-img" src="${esc(c.image)}" alt="">` : ""}
|
||||
<div class="car-head"><div class="name">${esc(c.name || t("Car"))}</div><div class="wt">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</div></div>
|
||||
<div class="car-main">
|
||||
${pct != null ? `<span class="big num">${pct}<span class="unit">%</span></span>` : ""}
|
||||
${range ? `<span class="car-range">${esc(fmtState(range))}<span class="wt"> ${t("range")}</span></span>` : ""}
|
||||
${low ? `<span class="level">${t("Low")}</span>` : ""}
|
||||
</div>
|
||||
${bar}
|
||||
${actions.length || odo ? `<div class="car-actions">${actions.join("")}${odo ? `<span class="wt car-odo">${esc(fmtState(odo))}</span>` : ""}</div>` : ""}
|
||||
</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
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) => `<b>${esc(fmtState(ha.states[eid]))}</b>`);
|
||||
return `<div class="text-w" style="font-size:${Math.max(13, Number(c.size) || 17)}px">${html}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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 `<div class="pad"><div class="empty">${empty}</div></div>`;
|
||||
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
||||
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
|
||||
},
|
||||
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 = `<div class="empty">${t("Couldn’t load photos. {0}", esc(cache.error))}</div>`; return; }
|
||||
if (!cache.items.length) { msg.innerHTML = `<div class="empty">${t("These albums have no photos yet.")}</div>`; 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)}<div class="empty">${t("Add one or more calendars in the inspector.")}</div>`;
|
||||
return `${title(c.title)}<div class="cal"><div class="empty">${t("Loading events…")}</div></div>`;
|
||||
},
|
||||
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 = `<div class="empty">${t("Couldn’t load events. {0}", esc(cache.error))}</div>`; 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 = `<div class="empty">${t("Nothing planned for the next {0} days.", days)}</div>`; 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 += `<div class="cal-day">${esc(label(day))}</div>`; }
|
||||
const when = ev.allDay ? t("All day") : `${hm(ev.s)}–${hm(ev.e)}`;
|
||||
const live = !ev.allDay && ev.s <= now && ev.e > now;
|
||||
html += `<div class="cal-ev ${live ? "live" : ""}"><span class="cal-bar" style="background:${ev.color}"></span>
|
||||
<span class="cal-when num">${esc(when)}</span>
|
||||
<span class="cal-what"><span class="cal-sum">${esc(ev.summary || t("Busy"))}</span>${ev.location || multi ? `<span class="cal-sub">${esc([multi ? ev.cal : "", ev.location].filter(Boolean).join(" · "))}</span>` : ""}</span>
|
||||
${live ? `<span class="cal-now">${t("Now")}</span>` : ""}</div>`;
|
||||
}
|
||||
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 ? `<iframe class="frame" src="${esc(c.url)}" loading="lazy" title="${t("Embedded page")}"></iframe>` : `<div class="pad"><div class="empty">${t("Add a URL in the inspector.")}</div></div>`),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 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)}<div class="empty">${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.")}</div>`;
|
||||
}
|
||||
const dot = (i) => `<span class="bin" style="--bin:${i.color}" aria-hidden="true">${Icons.svg("bin")}</span>`;
|
||||
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 = `<div class="waste-next ${soon ? "soon" : ""}">
|
||||
<div class="waste-bins">${first.map(dot).join("")}</div>
|
||||
<div class="waste-next-text"><span class="wt">${t("Next pickup")}</span>
|
||||
<span class="waste-when">${esc(whenLabel(first[0].days))}</span>
|
||||
<span class="waste-types">${esc(first.map((i) => i.name).join(" · "))}</span>
|
||||
${first[0].days === 1 ? `<span class="level">${t("Put the bin out tonight")}</span>` : ""}</div></div>`;
|
||||
}
|
||||
const rows = rest.map((i) => `<button class="list-row waste-row wbtn" data-act="more" data-e="${esc(i.eid)}">${dot(i)}
|
||||
<span class="grow">${esc(i.name)}</span><span class="waste-day ${i.days <= 1 ? "soon" : ""}">${esc(whenLabel(i.days))}</span><span class="st">${esc(shortDate(i.date))}</span></button>`).join("");
|
||||
return `${title(c.title)}${head}${rows ? `<div class="list waste-list">${rows}</div>` : ""}`;
|
||||
},
|
||||
};
|
||||
|
||||
window.Widgets = W;
|
||||
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
|
||||
})();
|
||||
Reference in New Issue
Block a user