Dashboard Studio 1.12.0: new cards, kiosk doorbell, night mode, editor tools, version history
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+638
-52
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,8 @@
|
||||
"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.",
|
||||
"Click to add. Drag to move, drag the corner to resize. Shift-click to select several. 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. Shift-klik om er meer te selecteren. Pijltjestoetsen verplaatsen de geselecteerde widget, Shift+pijltjes wijzigen het formaat, ⌘D dupliceert, Delete verwijdert.",
|
||||
"Cancel": "Annuleer",
|
||||
"Create": "Maak aan",
|
||||
"Rename": "Wijzig naam",
|
||||
@@ -301,6 +301,85 @@
|
||||
"Power switch": "Aan/uit-schakelaar", "Alerts, e.g. salt or rinse aid low": "Meldingen, bijv. zout of glansspoelmiddel bijna op",
|
||||
"Starts later": "Start later", "Action needed": "Actie nodig", "Error": "Fout", "Stopping": "Stoppen", "Door locked": "Deur vergrendeld",
|
||||
"Ready at {0}": "Klaar om {0}", "Check the appliance": "Kijk even bij het apparaat", "Something went wrong": "Er ging iets mis", "Stop": "Stop",
|
||||
"Hide this card when none of its cards are showing": "Verberg deze kaart als geen van de kaarten erin zichtbaar is",
|
||||
"Text when none of its cards are showing": "Tekst als geen van de kaarten erin zichtbaar is",
|
||||
"Choose an icon": "Kies een pictogram", "No icons match.": "Geen pictogrammen gevonden.", "Search, e.g. kitchen or keuken": "Zoek, bijv. keuken of slaapkamer",
|
||||
"Search icons": "Zoek pictogrammen", "You can also type an emoji in the field instead.": "Je kunt in het veld ook een emoji typen.", "No icon": "Geen pictogram",
|
||||
"Warm white": "Warm wit", "Neutral white": "Neutraal wit", "Cool white": "Koel wit", "Red": "Rood", "Orange": "Oranje", "Yellow": "Geel", "Green": "Groen",
|
||||
"Light blue": "Lichtblauw", "Blue": "Blauw", "Purple": "Paars", "Pink": "Roze", "More colors and settings": "Meer kleuren en instellingen",
|
||||
"Show colors": "Toon kleuren", "{0} of {1} on": "{0} van {1} aan", "Settings for {0}": "Instellingen voor {0}", "{0} brightness": "Helderheid {0}",
|
||||
"Brightness": "Helderheid", "Color temperature": "Kleurtemperatuur", "Color": "Kleur", "Any color": "Andere kleur",
|
||||
"Full at {0}": "Vol om {0}", "inside": "binnen", "Stop charging": "Stop laden", "Start charging": "Start laden",
|
||||
"Time until full (minutes or end time)": "Tijd tot vol (minuten of eindtijd)", "Start/stop charging (switch)": "Laden starten/stoppen (schakelaar)",
|
||||
"Car image URL (optional, a side view with a transparent background looks best)": "URL van autoafbeelding (optioneel, een zijaanzicht met transparante achtergrond werkt het best)",
|
||||
"To-do list": "Takenlijst", "List": "Lijst", "List name": "Naam van de lijst", "Show completed items": "Toon afgevinkte items", "a to-do list": "een takenlijst",
|
||||
"Delete {0}": "Verwijder {0}", "Add an item…": "Voeg iets toe…", "Add an item": "Voeg iets toe", "Nothing on the list.": "Er staat niets op de lijst.", "Loading…": "Laden…",
|
||||
"Hide completed ({0})": "Verberg afgevinkt ({0})", "Show completed ({0})": "Toon afgevinkt ({0})",
|
||||
"Who's home": "Wie is er thuis", "People (leave empty for everyone)": "Personen (leeg laten voor iedereen)", "grid": "Raster",
|
||||
"No people found. Add person entities in Home Assistant.": "Geen personen gevonden. Voeg personen toe in Home Assistant.",
|
||||
"Weather forecast": "Weersverwachting", "Show": "Toon", "both": "Beide", "hourly": "Per uur", "daily": "Per dag", "Hours ahead": "Aantal uren vooruit",
|
||||
"Show sunrise and sunset": "Toon zonsopkomst en -ondergang", "Loading the forecast…": "Verwachting laden…",
|
||||
"Timer": "Timer", "Quick starts in minutes": "Snelstart in minuten", "Beep when done": "Piep als hij klaar is", "a timer": "een timer", "Done!": "Klaar!", "OK": "OK", "min": "min",
|
||||
"Blinds": "Zonwering", "e.g. Living room": "bijv. Woonkamer", "Blinds, curtains or shutters": "Jaloezieën, gordijnen of rolluiken",
|
||||
"Add one or more blinds in the inspector.": "Voeg in de inspector een of meer jaloezieën toe.", "Open": "Open", "position": "positie", "tilt": "kanteling", "Tilt": "Kanteling",
|
||||
"Open all": "Alles open", "Close all": "Alles dicht",
|
||||
"Alarm": "Alarm", "Alarm panel": "Alarmsysteem", "Doors and windows to check (leave empty for all)": "Deuren en ramen om te controleren (leeg laten voor alle)",
|
||||
"an alarm panel": "een alarmsysteem", "On · home": "Aan · thuis", "On · away": "Aan · afwezig", "On · night": "Aan · nacht", "On · vacation": "Aan · vakantie",
|
||||
"Turning on…": "Inschakelen…", "Entry delay…": "Inloopvertraging…", "Alarm!": "Alarm!", "Away": "Afwezig", "Night": "Nacht", "Vacation": "Vakantie",
|
||||
"Enter the code": "Voer de code in", "Clear": "Wis", "Delete last digit": "Laatste cijfer wissen", "{0} still open: {1}": "{0} nog open: {1}",
|
||||
"Turn on the alarm anyway?": "Alarm toch inschakelen?",
|
||||
"Notifications": "Meldingen", "Show notifications": "Toon meldingen", "Show available updates": "Toon beschikbare updates", "{0} → {1}": "{0} → {1}",
|
||||
"Install": "Installeer", "Dismiss": "Wegklikken", "Dismiss all": "Alles wegklikken", "All caught up": "Alles bijgewerkt",
|
||||
"Install {0}?": "{0} installeren?", "Version {0} will be installed. Home Assistant may restart.": "Versie {0} wordt geïnstalleerd. Home Assistant kan opnieuw opstarten.",
|
||||
"Screen saver": "Schermbeveiliging",
|
||||
"clock": "Klok",
|
||||
"photos": "Foto's",
|
||||
"Photos from": "Foto's uit",
|
||||
"Doorbell": "Deurbel",
|
||||
"Camera to show": "Camera om te tonen",
|
||||
"Close after (seconds)": "Sluiten na (seconden)",
|
||||
"Play a sound": "Geluid afspelen",
|
||||
"When the doorbell rings in kiosk mode, the camera fills the screen.": "Als de deurbel gaat in kioskmodus, vult de camera het scherm.",
|
||||
"Someone’s at the door": "Er staat iemand voor de deur",
|
||||
"Night mode": "Nachtmodus",
|
||||
"After sunset": "Na zonsondergang",
|
||||
"Between times": "Tussen tijden",
|
||||
"From": "Van",
|
||||
"Until": "Tot",
|
||||
"Dim the screen (%)": "Scherm dimmen (%)",
|
||||
"Switch to the dark theme": "Overschakelen naar het donkere thema",
|
||||
"Night mode is saved on this device only, and is off while editing.": "Nachtmodus wordt alleen op dit apparaat bewaard en staat uit tijdens het bewerken.",
|
||||
"{0} widgets deleted. Press ⌘Z to undo.": "{0} widgets verwijderd. Druk op ⌘Z om ongedaan te maken.",
|
||||
"Copy {0} widgets to…": "{0} widgets kopiëren naar…",
|
||||
"Copy to…": "Kopiëren naar…",
|
||||
"Copy": "Kopiëren",
|
||||
"Copied to “{0}”.": "Gekopieerd naar “{0}”.",
|
||||
"Moved into “{0}”.": "Verplaatst naar “{0}”.",
|
||||
"{0} widgets selected": "{0} widgets geselecteerd",
|
||||
"Align": "Uitlijnen",
|
||||
"Left edges": "Linkerkanten",
|
||||
"Right edges": "Rechterkanten",
|
||||
"Top edges": "Bovenkanten",
|
||||
"Bottom edges": "Onderkanten",
|
||||
"Same width": "Zelfde breedte",
|
||||
"Same height": "Zelfde hoogte",
|
||||
"Same width and height follow the widget you clicked last.": "Zelfde breedte en hoogte volgen de widget waarop je het laatst klikte.",
|
||||
"Shift-click or ⌘-click widgets to add or remove them. Drag one to move them all; the arrow keys move them too.": "Shift-klik of ⌘-klik op widgets om ze toe te voegen of weg te halen. Sleep er één om ze allemaal te verplaatsen; de pijltjestoetsen werken ook.",
|
||||
"Take out of this card": "Uit deze kaart halen",
|
||||
"Drag a widget onto a scroll or swipe card to put it inside.": "Sleep een widget op een scroll- of veegkaart om hem erin te zetten.",
|
||||
"No matching entities": "Geen entiteiten gevonden",
|
||||
"Search widgets": "Widgets zoeken",
|
||||
"No widgets match “{0}”.": "Geen widgets gevonden voor “{0}”.",
|
||||
"Version history…": "Versiegeschiedenis…",
|
||||
"Version history": "Versiegeschiedenis",
|
||||
"Couldn’t load the version history. {0}": "De versiegeschiedenis kon niet worden geladen. {0}",
|
||||
"{0} widgets": "{0} widgets",
|
||||
"{0} tabs": "{0} tabbladen",
|
||||
"Current": "Huidige",
|
||||
"Restore": "Terugzetten",
|
||||
"Each save is kept here, up to 30 versions. Changes within five minutes count as one.": "Elke opslag wordt hier bewaard, tot 30 versies. Wijzigingen binnen vijf minuten tellen als één.",
|
||||
"No earlier versions yet. They appear after your next changes.": "Nog geen eerdere versies. Ze verschijnen na je volgende wijzigingen.",
|
||||
"Restored the version from {0}. Press ⌘Z to undo.": "De versie van {0} is teruggezet. Druk op ⌘Z om ongedaan te maken.",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
|
||||
@@ -86,11 +86,98 @@
|
||||
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",
|
||||
// rooms
|
||||
bed: "M3 18v-7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v7M3 15h18M7 9V7a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2M3 18v2M21 18v2",
|
||||
bath: "M4 12h16v3a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4zM6 12V6a2 2 0 0 1 3.5-1.3M7 19l-1 2M17 19l1 2",
|
||||
shower: "M4 21V8a4 4 0 0 1 8 0M9 8h6M11 12v.01M13 14v.01M15 12v.01M11 16v.01M15 16v.01M13 18v.01",
|
||||
kitchen: "M4 10h16v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4zM2 10h20M9 6c0-1 1-1.5 1-2.5M14 6c0-1 1-1.5 1-2.5",
|
||||
dining: "M7 3v8M5 3v4a2 2 0 0 0 4 0V3M7 11v10M17 3c-2 0-3 2-3 5s1 4 3 4v9",
|
||||
desk: "M3 10h18M5 10v9M19 10v9M14 14h5M8 4h8v6H8z",
|
||||
kids: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM9.5 13h.01M14.5 13h.01M10 16.5c1 .7 3 .7 4 0M7 8.5 5 6M17 8.5 19 6",
|
||||
laundry: "M4 9h16l-1.5 11a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM8 9l3-5M16 9l-3-5M9 13v4M15 13v4",
|
||||
stairs: "M3 20h5v-4h4v-4h4V8h5",
|
||||
tree: "M12 22v-6M12 16a5 5 0 0 0 5-5 5 5 0 0 0-2-4 4 4 0 0 0-6 0 5 5 0 0 0-2 4 5 5 0 0 0 5 5z",
|
||||
plant: "M12 21v-8M12 13c0-4 3-7 7-7 0 4-3 7-7 7zM12 15c0-3-2.5-5.5-6-5.5 0 3.5 2.5 5.5 6 5.5zM8 21h8",
|
||||
// tools
|
||||
wrench: "M14.7 6.3a4 4 0 0 0-5.4 5.2L3 17.8V21h3.2l6.3-6.3a4 4 0 0 0 5.2-5.4l-2.6 2.6-2.4-.6-.6-2.4z",
|
||||
hammer: "M14 6l4 4M11 3l6 6-2 2-6-6zM13 9l-9 9a1.5 1.5 0 0 0 2 2l9-9",
|
||||
toolbox: "M3 9h18v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zM8 9V6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3M3 14h18M10 14v2M14 14v2",
|
||||
sliders: "M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M14 4v4M8 10v4M16 16v4",
|
||||
key: "M8 15a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 11h9M18 11v3M15 11v2",
|
||||
// appliances
|
||||
fridge: "M6 2h12a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM5 10h14M9 5v2M9 13v3",
|
||||
oven: "M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 8h18M7 5.5h.01M11 5.5h.01M7 12h10v5H7z",
|
||||
microwave: "M3 5h18a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM5 8h11v8H5zM19 8v.01M19 11v.01M19 14v2",
|
||||
kettle: "M6 9h10l1 10a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1zM7 9l1-4h6l1 4M16 11h2a2 2 0 0 1 0 4h-1.5M9 3h4",
|
||||
coffee: "M5 9h11v6a5 5 0 0 1-5 5h-1a5 5 0 0 1-5-5zM16 11h1.5a2.5 2.5 0 0 1 0 5H16M8 3c0 1 1 1.5 1 2.5M12 3c0 1 1 1.5 1 2.5",
|
||||
tv: "M3 5h18a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM8 21h8M12 18v3",
|
||||
radiator: "M6 4v16M10 4v16M14 4v16M18 4v16M4 7h16M4 17h16",
|
||||
purifier: "M7 3h10a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM9 7h6M9 10h6M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z",
|
||||
aircon: "M3 5h18a1 1 0 0 1 1 1v6H2V6a1 1 0 0 1 1-1zM5 9h14M7 15c0 1.5-1 2-1 3M12 15c0 1.5-1 2-1 3M17 15c0 1.5-1 2-1 3",
|
||||
// devices
|
||||
phone: "M8 2h8a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM11 18h2",
|
||||
laptop: "M5 5h14a1 1 0 0 1 1 1v9H4V6a1 1 0 0 1 1-1zM2 18h20l-1.5 2h-17z",
|
||||
wifi: "M2 9a15 15 0 0 1 20 0M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5v.01",
|
||||
printer: "M7 8V3h10v5M17 16h4v-6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6h4M7 13h10v8H7z",
|
||||
server: "M4 3h16v7H4zM4 14h16v7H4zM8 6.5h.01M8 17.5h.01",
|
||||
gamepad: "M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6L14 15h-4l-1 .6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4zM7 10v4M5 12h4M15.5 11.5v.01M17.5 13.5v.01",
|
||||
headphones: "M4 15v-3a8 8 0 0 1 16 0v3M4 15a2 2 0 0 1 2-2h1v7H6a2 2 0 0 1-2-2zM20 15a2 2 0 0 0-2-2h-1v7h1a2 2 0 0 0 2-2z",
|
||||
bell: "M6 16v-5a6 6 0 0 1 12 0v5l2 2H4zM10 20a2 2 0 0 0 4 0",
|
||||
smoke: "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM4 12a8 8 0 0 0 16 0M7 4.5c1.5-1 3-1.5 5-1.5s3.5.5 5 1.5",
|
||||
leak: "M12 3s5 5.5 5 9a5 5 0 0 1-10 0c0-3.5 5-9 5-9zM3 21h18M6 18h.01M18 18h.01",
|
||||
solar: "M4 4h16l2 10H2zM2 14h20M8 4l-1 10M16 4l1 10M3 9h18M12 14v4M8 21h8",
|
||||
charger: "M6 3h8a1 1 0 0 1 1 1v17H5V4a1 1 0 0 1 1-1zM3 21h14M15 9h2a2 2 0 0 1 2 2v5a1.5 1.5 0 0 0 3 0V8l-3-3M10 7l-2 4h4l-2 4",
|
||||
lamp: "M9 3h6l3 7H6zM12 10v10M8 21h8",
|
||||
ceiling: "M12 2v5M7 12a5 5 0 0 1 10 0zM9 15l-1 2M12 15v3M15 15l1 2",
|
||||
ledstrip: "M3 8h18v8H3zM6 11v2M10 11v2M14 11v2M18 11v2",
|
||||
blinds: "M4 3h16M5 3v18M19 3v18M5 7h14M5 11h14M5 15h14M12 15v4",
|
||||
curtain: "M3 3h18M5 3c0 6 1 12 3 18M19 3c0 6-1 12-3 18M8 3c0 4 .5 9 2 13M16 3c0 4-.5 9-2 13",
|
||||
fence: "M3 21V7l3-3 3 3v14M15 21V7l3-3 3 3v14M3 11h18M3 17h18",
|
||||
mailbox: "M4 10a4 4 0 0 1 8 0v9H4zM12 10h6a2 2 0 0 1 2 2v7h-8M8 19v2M16 8V3h3v2h-3",
|
||||
paw: "M12 13c-3 0-5 3-5 5a2 2 0 0 0 2 2c1 0 2-.5 3-.5s2 .5 3 .5a2 2 0 0 0 2-2c0-2-2-5-5-5zM4.5 9.5a1.5 2 0 1 0 3 0 1.5 2 0 1 0-3 0M16.5 9.5a1.5 2 0 1 0 3 0 1.5 2 0 1 0-3 0M8 5.5a1.5 2 0 1 0 3 0 1.5 2 0 1 0-3 0M13 5.5a1.5 2 0 1 0 3 0 1.5 2 0 1 0-3 0",
|
||||
bike: "M2 17.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 1 0-7 0M15 17.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 1 0-7 0M5.5 17.5 9 10h6l3.5 7.5M9 10 8 7H6M15 10l-2.5 7.5",
|
||||
// for the new cards and the editor
|
||||
sunrise: "M12 3v5M8.5 6.5 12 3l3.5 3.5M6.3 14.3 5 13M17.7 14.3 19 13M8 18a4 4 0 0 1 8 0M2 21h20",
|
||||
sunset: "M12 8V3M8.5 4.5 12 8l3.5-3.5M6.3 14.3 5 13M17.7 14.3 19 13M8 18a4 4 0 0 1 8 0M2 21h20",
|
||||
umbrella: "M3 12a9 9 0 0 1 18 0zM12 12v7a2 2 0 0 0 4 0",
|
||||
timer: "M12 21a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM12 9v4l2.5 2M10 2h4M19 5l1.5-1.5",
|
||||
checkbox: "M5 4h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8 12l3 3 5-6",
|
||||
circle: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z",
|
||||
history: "M3 12a9 9 0 1 0 3-6.7M3 4v5h5M12 7v5l3 2",
|
||||
search: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-5-5",
|
||||
people: "M9 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM3 20v-1a6 6 0 0 1 12 0v1M16 5a3 3 0 0 1 0 6M21 20v-1a5 5 0 0 0-3-4.6",
|
||||
palette: "M12 21a9 9 0 1 1 9-9c0 2-1.5 3-3 3h-2a2 2 0 0 0-1.5 3.3A1.5 1.5 0 0 1 12 21zM7.5 11.5v.01M10 7.5v.01M14.5 7.5v.01M17 11.5v.01",
|
||||
keypad: "M6 4h.01M12 4h.01M18 4h.01M6 10h.01M12 10h.01M18 10h.01M6 16h.01M12 16h.01M18 16h.01M12 21h.01",
|
||||
"arrow-up": "M12 19V5M5 12l7-7 7 7",
|
||||
"arrow-down": "M12 5v14M19 12l-7 7-7-7",
|
||||
"copy-to": "M9 9h11v11H9zM5 15H4V4h11v1M13 14.5h4M15 12.5l2 2-2 2",
|
||||
};
|
||||
const FILLED = new Set(["play", "pause", "next", "prev"]);
|
||||
// Dutch (and some English) words for the icon search, so "keuken" or "slaapkamer" find something.
|
||||
const WORDS = {
|
||||
bed: "slaapkamer bedroom", bath: "badkamer bad bathroom", shower: "douche badkamer", kitchen: "keuken koken pan", dining: "eetkamer eten bestek",
|
||||
desk: "kantoor bureau werkkamer office", kids: "kinderkamer kind baby", laundry: "was wasmand washok", stairs: "trap overloop", tree: "tuin boom garden",
|
||||
plant: "plant bloem", sofa: "woonkamer bank living", garage: "garage", home: "huis thuis", wrench: "gereedschap moersleutel tool",
|
||||
hammer: "hamer gereedschap klus", toolbox: "gereedschapskist klus", sliders: "instellingen regelaar", key: "sleutel", fridge: "koelkast vriezer",
|
||||
oven: "oven fornuis", microwave: "magnetron", kettle: "waterkoker thee", coffee: "koffie", tv: "televisie scherm", radiator: "verwarming radiator heater",
|
||||
purifier: "luchtreiniger luchtbevochtiger", aircon: "airco koeling", phone: "telefoon mobiel", laptop: "laptop computer", wifi: "wifi router netwerk internet",
|
||||
printer: "printer", server: "server nas", gamepad: "spelcomputer games console", headphones: "koptelefoon", bell: "deurbel bel melding",
|
||||
smoke: "rookmelder brand", leak: "lekkage water", solar: "zonnepanelen zon", charger: "laadpaal auto laden", lamp: "lamp staande lamp",
|
||||
ceiling: "plafondlamp hanglamp", ledstrip: "ledstrip", blinds: "jaloezie lamellen rolluik", curtain: "gordijn", fence: "hek poort", mailbox: "brievenbus post",
|
||||
paw: "huisdier hond kat", bike: "fiets", bulb: "lamp licht", plug: "stopcontact stekker", fan: "ventilator", thermometer: "temperatuur thermostaat",
|
||||
droplet: "water vocht", washer: "wasmachine", dryer: "droger", dishwasher: "vaatwasser", vacuum: "stofzuiger robot", car: "auto", lock: "slot",
|
||||
camera: "camera", speaker: "speaker luidspreker", music: "muziek", door: "deur", window: "raam", timer: "timer kookwekker", people: "personen familie",
|
||||
};
|
||||
|
||||
/** Icon names matching a search, by name or by the words above. */
|
||||
function search(filter) {
|
||||
const f = String(filter || "").trim().toLowerCase();
|
||||
return Object.keys(P).filter((n) => !FILLED.has(n)).sort().filter((n) => !f || n.includes(f) || (WORDS[n] || "").includes(f));
|
||||
}
|
||||
|
||||
window.Icons = {
|
||||
names: Object.keys(P).filter((n) => !FILLED.has(n)),
|
||||
search,
|
||||
has: (n) => Object.prototype.hasOwnProperty.call(P, n),
|
||||
svg(name, cls = "") {
|
||||
if (!this.has(name)) return "";
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<button role="menuitem" data-menu="duplicate" data-icon="copy" data-i18n>Duplicate</button>
|
||||
<button role="menuitem" data-menu="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
|
||||
<button role="menuitem" data-menu="assign" data-icon="person" data-i18n>Who sees which dashboard…</button>
|
||||
<button role="menuitem" data-menu="history" data-icon="history" data-i18n>Version history…</button>
|
||||
<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>
|
||||
@@ -48,8 +49,9 @@
|
||||
<div id="workspace">
|
||||
<aside id="palette" class="edit-only" aria-label="Widgets">
|
||||
<div class="panel-title" data-i18n>Add widget</div>
|
||||
<input id="palette-search" class="input" type="search" aria-label="Search widgets" autocomplete="off" spellcheck="false">
|
||||
<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>
|
||||
<p class="hint" data-i18n>Click to add. Drag to move, drag the corner to resize. Shift-click to select several. Arrow keys move the selected widget, Shift+arrows resize, ⌘D duplicates, Delete removes.</p>
|
||||
</aside>
|
||||
|
||||
<main id="stage">
|
||||
@@ -61,7 +63,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="saver" hidden aria-hidden="true"><div class="time num"></div><div class="date"></div></div>
|
||||
<div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div>
|
||||
<div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div>
|
||||
<div id="night-dim" aria-hidden="true"></div>
|
||||
|
||||
<div id="modal" class="modal" hidden><div class="modal-card" role="dialog" aria-modal="true"></div></div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
|
||||
+283
-47
@@ -190,12 +190,20 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
|
||||
.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; }
|
||||
.ip-search { margin: 12px 0 10px; }
|
||||
.ip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 6px; max-height: 50vh; overflow-y: auto; padding: 2px; }
|
||||
.ip-icon { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 10px 4px 8px; border: 0; border-radius: 10px; background: var(--fill); cursor: pointer; min-width: 0; }
|
||||
.ip-icon .i { font-size: 22px; }
|
||||
.ip-icon span { font-size: 11px; color: var(--text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ip-icon:hover { background: var(--fill-strong); }
|
||||
.ip-icon.on { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
.icon-prev { border: 0; cursor: pointer; color: inherit; }
|
||||
.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; }
|
||||
.insp-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
|
||||
.insp-actions .btn { flex: 1 1 auto; white-space: nowrap; }
|
||||
.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; }
|
||||
@@ -305,6 +313,22 @@ 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; }
|
||||
body.editing .w.drop-into { outline: 3px solid var(--accent); outline-offset: 4px; }
|
||||
body.editing .w.drop-into::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: color-mix(in srgb, var(--accent) 18%, transparent); pointer-events: none; }
|
||||
.align-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.align-grid .btn { justify-content: center; }
|
||||
#palette-search { width: 100%; margin-bottom: 10px; }
|
||||
.ent-pop { position: fixed; z-index: 120; overflow-y: auto; background: var(--surface, var(--bg)); border: 1px solid var(--separator); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,.18); padding: 4px; }
|
||||
.ent-pop[hidden] { display: none; }
|
||||
.ep-item { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 8px; cursor: pointer; min-height: 40px; }
|
||||
.ep-item:hover { background: var(--fill); }
|
||||
.ep-item.on { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||
.ep-item > svg { width: 20px; height: 20px; flex: none; color: var(--text-2, inherit); }
|
||||
.ep-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.ep-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ep-id { font-size: 11px; color: var(--text-2, #8e8e93); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ep-state { font-size: 11px; color: var(--text-2, #8e8e93); flex: none; max-width: 30%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ep-none { padding: 10px; font-size: 13px; color: var(--text-2, #8e8e93); }
|
||||
.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; }
|
||||
@@ -411,7 +435,7 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
|
||||
.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 { display: inline-block; vertical-align: middle; 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); }
|
||||
@@ -653,6 +677,45 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.pos9 button[aria-pressed="true"] { background: var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); }
|
||||
.pos9 button[aria-pressed="true"] i { width: 14px; height: 10px; border-radius: 3px; background: var(--accent); opacity: 1; }
|
||||
|
||||
/* light card */
|
||||
.light-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.light-badge { cursor: pointer; }
|
||||
.light-toggle { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.light-switch { flex: none; display: flex; }
|
||||
.lm-switch { display: flex; }
|
||||
.swatches { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; flex: none; }
|
||||
.swatch { width: 26px; height: 26px; border-radius: 50%; background: var(--sw); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12); flex: none; display: grid; place-items: center; font-size: 15px; }
|
||||
.swatches.compact .swatch { width: 22px; height: 22px; }
|
||||
.swatch.more { background: var(--d-fill); }
|
||||
.swatch:hover { transform: scale(1.1); }
|
||||
.light.is-group { justify-content: flex-start; gap: 10px; }
|
||||
.light-members2 { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; margin: 0 -4px; padding: 0 4px; }
|
||||
.lm-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 12px; background: var(--d-fill); }
|
||||
.lm-dot { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; background: color-mix(in srgb, var(--d-text) 8%, transparent); }
|
||||
.lm-row.on .lm-dot { background: var(--tint, var(--d-lit)); color: #2a1d05; }
|
||||
.lm-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.lm-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lm-state { font-size: 12px; color: var(--d-text-2); }
|
||||
.lm-slider { height: 8px; border-radius: 4px; }
|
||||
.lm-slider::-webkit-slider-thumb { height: 16px; width: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.35); }
|
||||
.lm-slider::-moz-range-thumb { height: 16px; width: 16px; border-radius: 50%; background: #fff; }
|
||||
.lm-pct { font-size: 12px; color: var(--d-text-2); min-width: 32px; text-align: right; }
|
||||
.w.lit-lamp .lm-row { background: rgba(255, 255, 255, 0.45); }
|
||||
.w.lit-lamp .swatch.more { background: rgba(255, 255, 255, 0.55); }
|
||||
/* light popup */
|
||||
.lp-head { display: flex; align-items: center; gap: 12px; }
|
||||
.lp-head p { margin: 2px 0 0; }
|
||||
.lp-dot { width: 48px; height: 48px; border-radius: 50%; display: grid; place-items: center; font-size: 24px; background: var(--tint); color: #2a1d05; flex: none; }
|
||||
.lp-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 48px; border-radius: 14px; margin: 0; cursor: pointer;
|
||||
background: linear-gradient(to right, var(--fill-c, #ffd58a) var(--pct, 0%), var(--fill) var(--pct, 0%)); }
|
||||
.lp-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 6px; height: 30px; border-radius: 3px; background: rgba(255, 255, 255, .9); box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||
.lp-slider::-moz-range-thumb { width: 6px; height: 30px; border: 0; border-radius: 3px; background: #fff; }
|
||||
.lp-temp { height: 36px; background: linear-gradient(to right, #ffb35c, #fff4e5 55%, #cfe2ff); }
|
||||
.lp-swatches { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
||||
.swatch.big { width: 38px; height: 38px; border: 0; cursor: pointer; }
|
||||
.swatch.custom { background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red); color: #fff; position: relative; overflow: hidden; }
|
||||
.swatch.custom input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
|
||||
/* light groups */
|
||||
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
|
||||
.light:has(.light-members) { justify-content: flex-start; }
|
||||
@@ -677,6 +740,145 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.ef-totals { flex: none; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--d-text-2); justify-content: center; }
|
||||
.ef-total b { color: var(--d-text); font-size: 13px; }
|
||||
|
||||
/* to-do */
|
||||
.todo { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||
.todo-head { display: flex; align-items: center; gap: 8px; }
|
||||
.todo-head .name { flex: 1; min-width: 0; font-size: 17px; }
|
||||
.todo-count { font-size: 13px; font-weight: 700; min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px; display: grid; place-items: center; background: var(--d-fill); }
|
||||
.todo-add { display: flex; gap: 6px; flex: none; margin: 0; }
|
||||
.todo-input { flex: 1; min-width: 0; min-height: 40px; padding: 0 12px; border: 0; border-radius: 12px; background: var(--d-fill); color: inherit; font: inherit; outline: none; }
|
||||
.todo-input:focus { box-shadow: var(--focus); }
|
||||
.todo-plus { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: var(--d-text); color: var(--d-tile); font-size: 20px; flex: none; }
|
||||
.todo-list { flex: 1; min-height: 0; overflow-y: auto; margin: 0 -4px; padding: 0 4px; }
|
||||
.todo-row { display: flex; align-items: center; gap: 10px; min-height: 44px; border-bottom: 1px solid var(--d-separator); transition: transform .15s, opacity .15s; touch-action: pan-y; }
|
||||
.todo-check { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--d-text-2); display: grid; place-items: center; font-size: 14px; color: transparent; flex: none; }
|
||||
.todo-row.done .todo-check { background: var(--ok); border-color: var(--ok); color: #fff; }
|
||||
.todo-text { flex: 1; min-width: 0; font-size: 15px; display: flex; flex-direction: column; }
|
||||
.todo-text small { font-size: 12px; color: var(--d-text-2); }
|
||||
.todo-row.done .todo-text { color: var(--d-text-2); text-decoration: line-through; }
|
||||
.todo-del { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; color: var(--d-text-2); opacity: 0; font-size: 15px; }
|
||||
.todo-row:hover .todo-del, .todo-del:focus-visible { opacity: 1; }
|
||||
@media (hover: none) { .todo-del { opacity: .5; } }
|
||||
.todo-more { display: block; margin: 8px 0; font-size: 13px; font-weight: 600; color: var(--d-text-2); }
|
||||
.todo-empty { color: var(--d-text-2); font-size: 14px; padding: 12px 0; }
|
||||
|
||||
/* who's home */
|
||||
.people { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 10px; align-content: start; height: 100%; overflow-y: auto; }
|
||||
.pp { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; min-width: 0; padding: 4px 0; }
|
||||
.pp-av { position: relative; width: clamp(48px, 30cqh, 72px); aspect-ratio: 1; border-radius: 50%; background: var(--d-fill); display: grid; place-items: center; box-shadow: 0 0 0 3px var(--d-fill); }
|
||||
.pp.home .pp-av { box-shadow: 0 0 0 3px var(--ok); }
|
||||
.pp-av img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
|
||||
.pp:not(.home) .pp-av img { filter: grayscale(.7); opacity: .8; }
|
||||
.pp-init { font-size: clamp(16px, 9cqh, 24px); font-weight: 700; }
|
||||
.pp-dot { position: absolute; right: 2%; bottom: 2%; width: 26%; aspect-ratio: 1; border-radius: 50%; background: var(--d-text-2); box-shadow: 0 0 0 2px var(--w-bg, var(--d-tile)); }
|
||||
.pp.home .pp-dot { background: var(--ok); }
|
||||
.pp-text { display: flex; flex-direction: column; min-width: 0; max-width: 100%; }
|
||||
.pp-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pp-where { font-size: 12px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.people.as-list { grid-template-columns: 1fr; gap: 4px; }
|
||||
.people.as-list .pp { flex-direction: row; text-align: left; gap: 12px; }
|
||||
.people.as-list .pp-av { width: 40px; }
|
||||
|
||||
/* forecast */
|
||||
.fc { display: flex; flex-direction: column; height: 100%; gap: 12px; min-height: 0; }
|
||||
.fc-now { display: flex; align-items: center; gap: 12px; flex: none; }
|
||||
.fc-ico { font-size: 40px; display: flex; color: var(--d-text-2); }
|
||||
.fc-temp { font-size: 40px; font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
|
||||
.fc-cond { display: flex; flex-direction: column; min-width: 0; font-size: 13px; color: var(--d-text-2); }
|
||||
.fc-cond b { font-size: 15px; color: var(--d-text); }
|
||||
.fc-sun { margin-left: auto; display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: var(--d-text-2); }
|
||||
.fc-sun span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.fc-hours { display: flex; gap: 6px; overflow-x: auto; flex: none; scrollbar-width: none; padding-bottom: 2px; }
|
||||
.fc-hours::-webkit-scrollbar { display: none; }
|
||||
.fc-hour { flex: 0 0 52px; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 0; border-radius: 12px; background: var(--d-fill); font-size: 18px; }
|
||||
.fc-hour b { font-size: 14px; }
|
||||
.fc-t { font-size: 12px; color: var(--d-text-2); }
|
||||
.fc-rain { font-size: 11px; color: var(--d-accent); font-weight: 600; min-height: 13px; }
|
||||
.fc-days { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.fc-day { display: grid; grid-template-columns: 52px 22px 36px 34px 1fr 34px; align-items: center; gap: 8px; min-height: 36px; font-size: 14px; border-top: 1px solid var(--d-separator); }
|
||||
.fc-day .i { font-size: 18px; }
|
||||
.fc-dn { font-weight: 600; text-transform: capitalize; }
|
||||
.fc-lo { color: var(--d-text-2); text-align: right; }
|
||||
.fc-bar { position: relative; height: 6px; border-radius: 3px; background: var(--d-fill); }
|
||||
.fc-bar i { position: absolute; top: 0; bottom: 0; border-radius: 3px; background: linear-gradient(to right, #5ac8fa, #ffcc00, #ff9500); }
|
||||
@container (max-height: 300px) { .fc-days { display: none; } }
|
||||
@container (max-width: 360px) { .fc-sun { display: none; } }
|
||||
|
||||
/* timer */
|
||||
.tm { display: flex; flex-direction: column; align-items: center; height: 100%; gap: 8px; }
|
||||
.tm-name { align-self: flex-start; font-size: 15px; font-weight: 600; }
|
||||
.tm-ring { position: relative; flex: 1; min-height: 0; aspect-ratio: 1; display: grid; place-items: center; }
|
||||
.tm-ring > svg { position: absolute; inset: 0; transform: rotate(-90deg); }
|
||||
.tm-ring circle { fill: none; stroke-width: 7; }
|
||||
.tm-ring .track { stroke: var(--d-fill); }
|
||||
.tm-ring .fill { stroke: var(--d-accent); stroke-linecap: round; transition: stroke-dasharray 1s linear; }
|
||||
.tm.idle .tm-ring .fill { stroke: var(--d-text-2); opacity: .4; }
|
||||
.tm-time { font-size: clamp(22px, 14cqh, 40px); font-weight: 700; }
|
||||
.tm.done .tm-time { color: var(--ok); animation: tm-blink 1s ease-in-out infinite; }
|
||||
.tm.done .tm-ring .fill { stroke: var(--ok); }
|
||||
@keyframes tm-blink { 50% { opacity: .35; } }
|
||||
.tm-ctrls { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; flex: none; }
|
||||
|
||||
/* blinds */
|
||||
.cv { display: flex; flex-direction: column; height: 100%; gap: 10px; min-height: 0; }
|
||||
.cv-head { display: flex; align-items: center; gap: 6px; }
|
||||
.cv-head .name { flex: 1; min-width: 0; }
|
||||
.cv-win { position: relative; width: 44px; aspect-ratio: 3 / 4; border-radius: 6px; border: 2px solid var(--d-text-2); background: linear-gradient(#bfe0ff, #e8f4ff); overflow: hidden; flex: none; }
|
||||
.cv-blind { position: absolute; left: 0; right: 0; top: 0; background: repeating-linear-gradient(to bottom, var(--d-text-2) 0 3px, color-mix(in srgb, var(--d-text-2) 60%, transparent) 3px 5px); transition: height .4s; }
|
||||
.cv.one .cv-main { flex: 1; min-height: 0; display: flex; align-items: center; gap: 16px; }
|
||||
.cv.one .cv-win { width: auto; height: min(100%, 150px); }
|
||||
.cv-side { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.cv-pct { font-size: 28px; font-weight: 700; }
|
||||
.cv-btns { display: flex; gap: 6px; flex: none; }
|
||||
.cv-btns .round { width: 40px; height: 40px; }
|
||||
.cv-btns .round:disabled { opacity: .35; cursor: default; }
|
||||
.cv-tilt { display: flex; align-items: center; gap: 10px; }
|
||||
.cv-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.cv-row { display: flex; align-items: center; gap: 12px; }
|
||||
.cv-row .cv-win { width: 30px; }
|
||||
.cv-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.cv-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cv-name b { color: var(--d-text-2); font-weight: 600; }
|
||||
.cv-slider { height: 10px; }
|
||||
|
||||
/* alarm */
|
||||
.al { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||
.al-head { display: flex; align-items: center; gap: 12px; }
|
||||
.al-shield { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-size: 22px; background: var(--d-fill); flex: none; }
|
||||
.al[class*="state-armed"] .al-shield { background: var(--d-accent); color: var(--d-on-accent, #fff); }
|
||||
.al.state-triggered .al-shield { background: var(--crit); color: #fff; animation: tm-blink 1s infinite; }
|
||||
.al.state-disarmed .al-shield { color: var(--d-text-2); }
|
||||
.al-state { font-size: 14px; color: var(--d-text-2); font-weight: 600; }
|
||||
.al.state-triggered .al-state { color: var(--crit); }
|
||||
.al-open { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 12px; color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); font-size: 13px; font-weight: 600; }
|
||||
.al-open span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.al-code { text-align: center; font-size: 26px; letter-spacing: .3em; min-height: 32px; }
|
||||
.al-code .wt { letter-spacing: 0; font-size: 14px; }
|
||||
.al-keys { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; flex: 1; min-height: 0; }
|
||||
.al-key { display: grid; place-items: center; min-height: 40px; border-radius: 12px; background: var(--d-fill); font-size: 20px; font-weight: 600; }
|
||||
.al-key:active { background: color-mix(in srgb, var(--d-text) 20%, transparent); }
|
||||
.al-modes { display: flex; gap: 6px; flex: none; }
|
||||
.al-mode { flex: 1; min-height: 44px; border-radius: 12px; display: grid; place-items: center; background: var(--d-fill); font-weight: 600; font-size: 14px; }
|
||||
.al-mode.on { background: var(--d-accent); color: var(--d-on-accent, #fff); }
|
||||
.al-mode.off { background: var(--d-text); color: var(--d-tile); }
|
||||
|
||||
/* notifications */
|
||||
.nt { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||
.nt-head { display: flex; align-items: center; gap: 8px; }
|
||||
.nt-head .name { flex: 1; font-size: 17px; }
|
||||
.nt-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.nt-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px; border-radius: 12px; background: var(--d-fill); }
|
||||
.nt-item.update .nt-ico { color: var(--d-accent); }
|
||||
.nt-ico { font-size: 18px; display: flex; padding-top: 1px; color: var(--d-text-2); }
|
||||
.nt-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; font-size: 13px; }
|
||||
.nt-body b { font-size: 14px; }
|
||||
.nt-msg { color: var(--d-text-2); overflow-wrap: anywhere; }
|
||||
.nt-msg a, .nt-link { color: var(--d-accent); }
|
||||
.nt-time { font-size: 11px; color: var(--d-text-2); }
|
||||
.nt-x { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; color: var(--d-text-2); }
|
||||
.nt-empty { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--d-text-2); font-size: 14px; }
|
||||
.nt-empty .i { font-size: 28px; color: var(--ok); }
|
||||
|
||||
/* room */
|
||||
.room { position: relative; display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; }
|
||||
.room.on-image { margin: -16px; padding: 16px; border-radius: inherit; color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); --d-fill: rgba(255, 255, 255, 0.18);
|
||||
@@ -772,51 +974,63 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
/* pill button with icon and label (media "Turn on") */
|
||||
.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: built around 3×4; compact when short, side by side when wide */
|
||||
.car2 { display: grid; grid-template-areas: "top" "visual" "info"; grid-template-rows: auto 1fr auto; gap: 10px; height: 100%; min-height: 0; }
|
||||
.car-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.car-top .name { font-size: 17px; flex: 1; min-width: 0; }
|
||||
.car-status { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
|
||||
.car-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
|
||||
.car-visual { grid-area: visual; position: relative; min-height: 0; display: grid; place-items: center; }
|
||||
.car-photo { width: 100%; height: 100%; object-fit: contain; }
|
||||
.car-art { width: 100%; max-height: 100%; }
|
||||
.car-body { fill: var(--d-fill); stroke: var(--d-text-2); stroke-width: 1.5; }
|
||||
.car-glass { fill: color-mix(in srgb, var(--d-text) 12%, transparent); }
|
||||
.car-wheel { fill: var(--d-text); opacity: .85; }
|
||||
.car-hub { fill: var(--d-tile); }
|
||||
.car-visual.is-charging::before { content: ""; position: absolute; inset: 15% 10%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 35%, transparent), transparent); animation: glow 2.4s ease-in-out infinite; }
|
||||
@keyframes glow { 50% { opacity: .45; } }
|
||||
.car-bolt { position: absolute; right: 8%; top: 4%; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: 17px; }
|
||||
.car-info { grid-area: info; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.car-nums { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.car-pct { font-size: clamp(32px, 14cqh, 52px); font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
|
||||
.car-range { font-size: 17px; font-weight: 600; color: var(--d-text-2); }
|
||||
.car2 .level { color: var(--crit); align-self: center; }
|
||||
.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-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; }
|
||||
.car-stat { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); min-width: 0; }
|
||||
.car-stat-v { font-size: 15px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.car-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.car-actions2 { display: flex; gap: 6px; }
|
||||
.car-btn { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); }
|
||||
.car-btn-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; }
|
||||
.car-btn[aria-pressed="true"] .car-btn-ico { background: var(--d-text); color: var(--d-tile); }
|
||||
.car-btn-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* sizes */
|
||||
@container (max-height: 380px) { .car-stats { display: none; } }
|
||||
@container (max-height: 250px) and (max-width: 479px) { .car2 { grid-template-areas: "top" "info"; grid-template-rows: auto 1fr; } .car-visual { display: none; } .car-info { justify-content: flex-end; } }
|
||||
@container (max-height: 160px) { .car-actions2 { display: none; } }
|
||||
@container (min-width: 480px) and (min-height: 150px) {
|
||||
.car2 { grid-template-areas: "top top" "visual info"; grid-template-columns: 1.1fr 1fr; grid-template-rows: auto 1fr; column-gap: 18px; }
|
||||
.car-info { justify-content: center; }
|
||||
/* car: a round gauge with the range in the middle and the car in front */
|
||||
.car3 { --car-top: color-mix(in srgb, var(--d-accent) 55%, var(--d-text)); --car-bottom: color-mix(in srgb, var(--d-accent) 70%, #000);
|
||||
display: grid; grid-template-areas: "top" "gauge" "info"; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; height: 100%; min-height: 0; }
|
||||
.car3-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.car3-top .name { font-size: 17px; flex: 1; min-width: 0; }
|
||||
.car3-status { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 62%; }
|
||||
.car3-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
|
||||
.cg { grid-area: gauge; position: relative; min-height: 0; height: 100%; max-height: 260px; max-width: 100%; justify-self: center; align-self: center; aspect-ratio: 1 / .92; }
|
||||
.cg > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.cg > svg path { fill: none; stroke-width: 12; stroke-linecap: round; }
|
||||
.cg-track { stroke: var(--d-fill); }
|
||||
.cg-fill { stroke: var(--d-text); }
|
||||
.cg.charging .cg-fill { stroke: var(--ok); animation: cg-pulse 2.4s ease-in-out infinite; }
|
||||
.cg.low .cg-fill { stroke: var(--crit); }
|
||||
@keyframes cg-pulse { 50% { opacity: .65; } }
|
||||
.cg-limit { stroke: var(--d-text-2); stroke-width: 3; stroke-linecap: round; }
|
||||
.cg-center { position: absolute; left: 0; right: 0; top: 22%; display: flex; flex-direction: column; align-items: center; }
|
||||
.cg-range { font-size: clamp(26px, 11cqh, 44px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
|
||||
.cg-range small { font-size: .42em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; }
|
||||
.cg-pct { margin-top: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); }
|
||||
.cg.charging .cg-pct { color: var(--ok); }
|
||||
.cg-car { position: absolute; left: 6%; right: 6%; bottom: 2%; display: flex; justify-content: center; }
|
||||
.cg-car img { max-width: 100%; max-height: 72px; object-fit: contain; filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .3)); }
|
||||
.car-art2 { width: 100%; max-height: 74px; }
|
||||
.car-shadow { fill: rgba(0, 0, 0, .28); }
|
||||
.car-body2 { fill: url(#carBody); }
|
||||
.car-glass2 { fill: url(#carGlass); }
|
||||
.car-line { stroke: rgba(255, 255, 255, .25); stroke-width: 1; }
|
||||
.car-light { fill: #fff6d6; }
|
||||
.car-tire { fill: #17181b; } .car-rim { fill: #8e9097; }
|
||||
.car3.is-charging .cg-car::after { content: ""; position: absolute; left: 20%; right: 20%; bottom: 0; height: 40%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 45%, transparent), transparent); z-index: -1; }
|
||||
.car3-info { grid-area: info; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.cchips { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
|
||||
.cchip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
|
||||
.cchip .i { font-size: 14px; color: var(--d-text-2); }
|
||||
.cbs { display: flex; gap: 6px; }
|
||||
.cb { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); }
|
||||
.cb-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; transition: background-color .2s; }
|
||||
.cb[aria-pressed="true"] .cb-ico { background: var(--d-text); color: var(--d-tile); }
|
||||
.cb-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cb-sub { font-size: 11px; color: var(--d-text-2); white-space: nowrap; }
|
||||
/* short: gauge small on the left, numbers and buttons beside it */
|
||||
@container (max-height: 250px) {
|
||||
.car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; column-gap: 14px; }
|
||||
.cg { width: auto; height: 100%; max-width: none; aspect-ratio: 1; }
|
||||
.cg-car { display: none; }
|
||||
.cg-center { top: 30%; }
|
||||
.car3-info { justify-content: center; }
|
||||
.cchips { justify-content: flex-start; }
|
||||
}
|
||||
@container (max-width: 230px) { .car-btn-label { display: none; } .car-range { font-size: 15px; } }
|
||||
@container (max-height: 160px) { .cbs { display: none; } }
|
||||
/* wide: gauge on the left, the rest on the right */
|
||||
@container (min-width: 480px) and (min-height: 251px) {
|
||||
.car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto 1fr; column-gap: 20px; }
|
||||
.car3-info { justify-content: center; }
|
||||
}
|
||||
@container (max-width: 300px) { .cb-label, .cb-sub { display: none; } .cb { padding: 6px 2px; } }
|
||||
|
||||
/* ============================================================ phone dashboards */
|
||||
body.phone-ui:not(.peek) #topbar { display: none; }
|
||||
@@ -833,6 +1047,23 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
|
||||
#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; }
|
||||
#saver .photos { display: none; }
|
||||
#saver.photo .photos { display: block; }
|
||||
#saver.photo .saver-clock { position: absolute; left: 32px; bottom: 28px; text-align: left; color: #fff; text-shadow: 0 1px 12px rgba(0,0,0,.55); }
|
||||
#saver.photo .time { font-size: clamp(40px, 9vw, 96px); }
|
||||
#saver.photo .date { color: rgba(255,255,255,.85); font-size: 18px; margin-top: 4px; }
|
||||
#saver .ph-msg { position: absolute; inset: 0; display: grid; place-items: center; }
|
||||
#doorbell { position: fixed; inset: 0; z-index: 90; background: #000; display: flex; flex-direction: column; }
|
||||
#doorbell[hidden] { display: none; }
|
||||
#doorbell .db-cam { flex: 1; min-height: 0; display: grid; place-items: center; }
|
||||
#doorbell .db-cam > img { width: 100%; height: 100%; object-fit: contain; }
|
||||
#doorbell .db-icon > svg { width: 120px; height: 120px; color: #fff; animation: db-ring 1s ease-in-out infinite; }
|
||||
@keyframes db-ring { 0%, 100% { transform: rotate(0); } 25% { transform: rotate(12deg); } 75% { transform: rotate(-12deg); } }
|
||||
#doorbell .db-bar { display: flex; align-items: center; gap: 16px; padding: 18px 24px calc(18px + env(safe-area-inset-bottom)); background: rgba(28,28,30,.9); color: #fff; }
|
||||
#doorbell .db-title { flex: 1; font-size: 22px; font-weight: 600; }
|
||||
#doorbell .db-close { min-height: 52px; padding: 0 28px; font-size: 18px; }
|
||||
#night-dim { position: fixed; inset: 0; z-index: 70; pointer-events: none; background: #000; opacity: var(--night-dim, 0); transition: opacity 2s ease; }
|
||||
@media (prefers-reduced-motion: reduce) { #doorbell .db-icon > svg { animation: none; } #night-dim { transition: none; } }
|
||||
|
||||
/* ============================================================ motion + sizes */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -874,3 +1105,8 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
|
||||
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; }
|
||||
}
|
||||
|
||||
.hist-list { display: flex; flex-direction: column; gap: 2px; max-height: 50vh; overflow-y: auto; margin-top: 12px; }
|
||||
.hist-row { display: flex; align-items: center; gap: 12px; padding: 8px 4px; border-bottom: 1px solid var(--separator); }
|
||||
.hist-row:last-child { border-bottom: 0; }
|
||||
.hist-when { font-weight: 500; }
|
||||
|
||||
+376
-63
@@ -202,11 +202,36 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- light (and light groups)
|
||||
const COLOR_MODES = ["hs", "xy", "rgb", "rgbw", "rgbww"];
|
||||
const canColor = (s) => (s?.attributes.supported_color_modes || []).some((m) => COLOR_MODES.includes(m));
|
||||
const canTemp = (s) => (s?.attributes.supported_color_modes || []).includes("color_temp");
|
||||
// Swatches: three whites by color temperature, then colors. Values are what light.turn_on takes.
|
||||
const WHITES = [["k:2700", "#ffc46b", "Warm white"], ["k:4000", "#ffe9cf", "Neutral white"], ["k:6500", "#dbe9ff", "Cool white"]];
|
||||
const COLORS = [["rgb:255,59,48", "#ff3b30", "Red"], ["rgb:255,149,0", "#ff9500", "Orange"], ["rgb:255,214,10", "#ffd60a", "Yellow"], ["rgb:52,199,89", "#34c759", "Green"],
|
||||
["rgb:90,200,250", "#5ac8fa", "Light blue"], ["rgb:0,122,255", "#007aff", "Blue"], ["rgb:175,82,222", "#af52de", "Purple"], ["rgb:255,45,85", "#ff2d55", "Pink"]];
|
||||
/** The color a light shows as: its RGB color, or a white from its color temperature. */
|
||||
function lightTint(s) {
|
||||
const a = s?.attributes || {};
|
||||
if (s?.state !== "on") return "";
|
||||
if (Array.isArray(a.rgb_color) && a.color_mode !== "color_temp") return `rgb(${a.rgb_color.join(",")})`;
|
||||
const k = Number(a.color_temp_kelvin);
|
||||
return isFinite(k) && k > 0 ? (k < 3300 ? "#ffc46b" : k < 5000 ? "#ffe9cf" : "#dbe9ff") : "#ffd58a";
|
||||
}
|
||||
function swatches(eid, s, { compact = false } = {}) {
|
||||
const list = [...(canTemp(s) ? WHITES : []), ...(canColor(s) ? COLORS : [])];
|
||||
if (!list.length) return "";
|
||||
return `<div class="swatches ${compact ? "compact" : ""}" role="group" aria-label="${t("Colors")}">${(compact ? list.slice(0, 7) : list).map(([v, col, lbl]) =>
|
||||
`<button class="swatch wbtn" style="--sw:${col}" data-act="light-color" data-e="${esc(eid)}" data-v="${v}" aria-label="${esc(t(lbl))}" title="${esc(t(lbl))}"></button>`).join("")}
|
||||
<button class="swatch more wbtn" data-act="light-popup" data-e="${esc(eid)}" aria-label="${t("More colors and settings")}" title="${t("More colors and settings")}">${Icons.svg("palette")}</button></div>`;
|
||||
}
|
||||
|
||||
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" },
|
||||
{ k: "colors", t: "check", l: "Show colors", d: true },
|
||||
{ k: "members", t: "check", l: "For a light group, show each light", d: true },
|
||||
],
|
||||
// A light group lists its lights in the entity_id attribute; follow them too.
|
||||
@@ -214,20 +239,33 @@
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a light");
|
||||
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id : [];
|
||||
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id.filter((e) => ha.states[e]) : [];
|
||||
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">` : ""}
|
||||
${members.length ? `<div class="list light-members" role="group" aria-label="${t("Lights in {0}", esc(name))}">${members.map((eid) => {
|
||||
const m = ha.states[eid];
|
||||
if (!m) return "";
|
||||
return `<button class="list-row wbtn" role="switch" aria-checked="${isOn(m)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(m)}</span><span class="grow">${esc(friendly(m))}</span><span class="st">${esc(fmtState(m))}</span><span class="switch"></span></button>`;
|
||||
}).join("")}</div>` : ""}</div>`;
|
||||
const onCount = members.filter((e) => ha.states[e].state === "on").length;
|
||||
const state = members.length ? (onCount ? t("{0} of {1} on", onCount, members.length) : t("Off")) : fmtState(s);
|
||||
const head = `<div class="light-head">
|
||||
<button class="badge light-badge wbtn" data-act="light-popup" data-e="${esc(c.entity)}" aria-label="${esc(t("Settings for {0}", name))}">${icon(s)}</button>
|
||||
<button class="tile-text light-toggle wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}"><span class="name">${esc(name)}</span><span class="state">${esc(state)}${pct && members.length ? ` · ${pct}%` : ""}</span></button>
|
||||
<button class="light-switch wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(c.entity)}" aria-label="${esc(name)}"><span class="switch"></span></button></div>`;
|
||||
const slider = 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(t("{0} brightness", name))}">` : "";
|
||||
const colors = c.colors !== false ? swatches(c.entity, s, { compact: !!members.length }) : "";
|
||||
|
||||
// In a group, every light gets a row: its color (tap for its own settings), name, brightness and switch.
|
||||
const rows = members.map((eid) => {
|
||||
const m = ha.states[eid], on = m.state === "on", mp = on ? Math.round((m.attributes.brightness || 255) / 2.55) : 0;
|
||||
const tint = lightTint(m), mdim = (m.attributes.supported_color_modes || []).some((x) => x !== "onoff");
|
||||
const short = friendly(m).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "") || friendly(m);
|
||||
return `<div class="lm-row ${on ? "on" : ""}">
|
||||
<button class="lm-dot wbtn" style="${tint ? `--tint:${tint}` : ""}" data-act="light-popup" data-e="${esc(eid)}" aria-label="${esc(t("Settings for {0}", friendly(m)))}">${icon(m)}</button>
|
||||
<div class="lm-main"><span class="lm-name">${esc(short)}</span>
|
||||
${mdim ? `<input class="slider lm-slider" type="range" min="1" max="100" value="${mp || 1}" style="--pct:${mp}%" data-act="brightness" data-e="${esc(eid)}" aria-label="${esc(t("{0} brightness", friendly(m)))}">` : `<span class="lm-state">${esc(fmtState(m))}</span>`}</div>
|
||||
<span class="lm-pct num">${on && mdim ? `${mp}%` : ""}</span>
|
||||
<button class="lm-switch wbtn" role="switch" aria-checked="${on}" data-act="toggle" data-e="${esc(eid)}" aria-label="${esc(friendly(m))}"><span class="switch"></span></button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
return `<div class="light ${members.length ? "is-group" : ""}">${head}${slider}${colors}${rows ? `<div class="light-members2" role="group" aria-label="${t("Lights in {0}", esc(name))}">${rows}</div>` : ""}</div>`;
|
||||
},
|
||||
lit: (c, { ha }) => litKind(ha.states[c.entity]),
|
||||
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
|
||||
@@ -477,6 +515,254 @@
|
||||
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- to-do / shopping list
|
||||
// Items come from todo/item/list; adding, ticking off and deleting use the todo actions (handled in app.js).
|
||||
W.todo = {
|
||||
label: "To-do list", icon: "checkbox", size: [4, 4],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "List", domains: ["todo"] },
|
||||
{ k: "title", t: "text", l: "Title", ph: "List name" },
|
||||
{ k: "showDone", t: "check", l: "Show completed items", d: false },
|
||||
],
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a to-do list");
|
||||
const items = cache.items || [];
|
||||
const open = items.filter((i) => i.status !== "completed"), done = items.filter((i) => i.status === "completed");
|
||||
const row = (i) => `<div class="todo-row ${i.status === "completed" ? "done" : ""}" data-uid="${esc(i.uid)}">
|
||||
<button class="todo-check wbtn" role="checkbox" aria-checked="${i.status === "completed"}" data-act="todo-toggle" data-e="${esc(c.entity)}" data-v="${esc(i.uid)}" aria-label="${esc(i.summary)}">${Icons.svg("check")}</button>
|
||||
<span class="todo-text">${esc(i.summary)}${i.due ? `<small>${esc(new Date(i.due).toLocaleDateString(LOCALE, { day: "numeric", month: "short" }))}</small>` : ""}</span>
|
||||
<button class="todo-del wbtn" data-act="todo-del" data-e="${esc(c.entity)}" data-v="${esc(i.uid)}" aria-label="${esc(t("Delete {0}", i.summary))}">${Icons.svg("close")}</button></div>`;
|
||||
const showDone = cache.showDone ?? c.showDone;
|
||||
return `<div class="todo">
|
||||
<div class="todo-head"><span class="name">${esc(c.title || friendly(s))}</span><span class="todo-count num">${open.length}</span></div>
|
||||
<form class="todo-add" data-e="${esc(c.entity)}"><input class="todo-input" type="text" placeholder="${t("Add an item…")}" aria-label="${t("Add an item")}" enterkeyhint="done">
|
||||
<button type="button" class="todo-plus wbtn" data-act="todo-add" data-e="${esc(c.entity)}" aria-label="${t("Add")}">${Icons.svg("plus")}</button></form>
|
||||
<div class="todo-list">${cache.items ? open.map(row).join("") || `<div class="todo-empty">${t("Nothing on the list.")}</div>` : `<div class="todo-empty">${t("Loading…")}</div>`}
|
||||
${done.length ? `<button class="todo-more wbtn" data-act="todo-showdone">${showDone ? t("Hide completed ({0})", done.length) : t("Show completed ({0})", done.length)}</button>${showDone ? done.map(row).join("") : ""}` : ""}</div></div>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
const s = ctx.ha.states[c.entity];
|
||||
if (!s) return;
|
||||
// The list's state is the number of open items; refetch when it (or the card) changes.
|
||||
const key = `${c.entity}|${s.last_updated}|${ctx.cache.bump || 0}`;
|
||||
if (ctx.cache.key !== key) {
|
||||
ctx.cache.key = key;
|
||||
try { ctx.cache.items = (await ctx.ha.send({ type: "todo/item/list", entity_id: c.entity }))?.items || []; } catch { ctx.cache.items = ctx.cache.items || []; }
|
||||
if (el.isConnected) ctx.rerender();
|
||||
return;
|
||||
}
|
||||
// Swipe a row to the left to delete it (touch); the ✕ does the same with a mouse.
|
||||
el.querySelectorAll(".todo-row").forEach((r) => {
|
||||
let x0 = null, dx = 0;
|
||||
r.addEventListener("pointerdown", (e) => { if (e.pointerType !== "mouse" && !e.target.closest("button")) { x0 = e.clientX; dx = 0; } });
|
||||
r.addEventListener("pointermove", (e) => { if (x0 == null) return; dx = Math.min(0, e.clientX - x0); r.style.transform = `translateX(${dx}px)`; r.style.opacity = String(1 + dx / 300); });
|
||||
const end = () => { if (x0 == null) return; x0 = null; if (dx < -90) r.querySelector(".todo-del")?.click(); else { r.style.transform = ""; r.style.opacity = ""; } };
|
||||
r.addEventListener("pointerup", end); r.addEventListener("pointercancel", end);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- who's home
|
||||
W.people = {
|
||||
label: "Who's home", icon: "people", size: [4, 2],
|
||||
fields: [{ k: "entities", t: "entities", l: "People (leave empty for everyone)", domains: ["person"] }, { k: "layout", t: "seg", l: "Layout", o: ["grid", "list"], d: "grid" }],
|
||||
entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("person."))),
|
||||
render(c, { ha }) {
|
||||
const ids = (c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => e.startsWith("person."))).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return `<div class="empty">${t("No people found. Add person entities in Home Assistant.")}</div>`;
|
||||
const ppl = ids.map((e) => {
|
||||
const s = ha.states[e], home = s.state === "home", away = s.state === "not_home";
|
||||
const where = home ? t("Home") : away ? t("Away") : st(s.state);
|
||||
const name = friendly(s), initials = name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
const pic = s.attributes.entity_picture ? `<img src="${esc(s.attributes.entity_picture)}" alt="">` : `<span class="pp-init">${esc(initials)}</span>`;
|
||||
return `<button class="pp ${home ? "home" : ""} wbtn" data-act="more" data-e="${esc(e)}" aria-label="${esc(`${name}: ${where}`)}">
|
||||
<span class="pp-av">${pic}<span class="pp-dot" aria-hidden="true"></span></span>
|
||||
<span class="pp-text"><span class="pp-name">${esc(name.split(" ")[0])}</span><span class="pp-where">${esc(where)} · ${esc(relTime(s.last_changed).replace(/ geleden| ago/, ""))}</span></span></button>`;
|
||||
}).join("");
|
||||
return `<div class="people ${c.layout === "list" ? "as-list" : ""}">${ppl}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- weather forecast
|
||||
W.forecast = {
|
||||
label: "Weather forecast", icon: "umbrella", size: [6, 4],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Weather", domains: ["weather"] },
|
||||
{ k: "show", t: "seg", l: "Show", o: ["both", "hourly", "daily"], d: "both" },
|
||||
{ k: "hours", t: "number", l: "Hours ahead", d: 12 },
|
||||
{ k: "days", t: "number", l: "Days ahead", d: 5 },
|
||||
{ k: "sun", t: "check", l: "Show sunrise and sunset", d: true },
|
||||
],
|
||||
entities: (c) => [c.entity, "sun.sun"].filter(Boolean),
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a weather entity");
|
||||
const a = s.attributes, unit = a.temperature_unit || "°";
|
||||
const deg = (v) => (v == null ? "–" : `${Math.round(v)}°`);
|
||||
const today = cache.daily?.[0];
|
||||
const sun = ha.states["sun.sun"]?.attributes;
|
||||
const hm = (iso) => new Date(iso).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
|
||||
const hourly = c.show !== "daily" && cache.hourly ? `<div class="fc-hours">${cache.hourly.slice(0, Math.max(3, Number(c.hours) || 12)).map((h) =>
|
||||
`<div class="fc-hour"><span class="fc-t">${esc(new Date(h.datetime).toLocaleTimeString(LOCALE, { hour: "2-digit" }))}</span>${Icons.svg(WX[h.condition] || "cloud")}<b class="num">${deg(h.temperature)}</b>${h.precipitation_probability ? `<span class="fc-rain">${Math.round(h.precipitation_probability)}%</span>` : `<span class="fc-rain"></span>`}</div>`).join("")}</div>` : "";
|
||||
let daily = "";
|
||||
if (c.show !== "hourly" && cache.daily) {
|
||||
const days = cache.daily.slice(0, Math.max(2, Number(c.days) || 5));
|
||||
const lo = Math.min(...days.map((d) => d.templow ?? d.temperature)), hi = Math.max(...days.map((d) => d.temperature));
|
||||
daily = `<div class="fc-days">${days.map((d, i) => {
|
||||
const l = d.templow ?? d.temperature, left = ((l - lo) / (hi - lo || 1)) * 100, right = ((d.temperature - lo) / (hi - lo || 1)) * 100;
|
||||
return `<div class="fc-day"><span class="fc-dn">${i === 0 ? t("Today") : esc(new Date(d.datetime).toLocaleDateString(LOCALE, { weekday: "short" }))}</span>${Icons.svg(WX[d.condition] || "cloud")}
|
||||
<span class="fc-rain">${d.precipitation_probability ? `${Math.round(d.precipitation_probability)}%` : ""}</span><span class="fc-lo num">${deg(l)}</span>
|
||||
<span class="fc-bar"><i style="left:${left}%;right:${100 - right}%"></i></span><span class="fc-hi num">${deg(d.temperature)}</span></div>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
return `<div class="fc">
|
||||
<button class="fc-now wbtn" data-act="more" data-e="${esc(c.entity)}"><span class="fc-ico">${Icons.svg(WX[s.state] || "cloud")}</span>
|
||||
<span class="fc-temp num">${esc(a.temperature ?? "–")}°</span>
|
||||
<span class="fc-cond"><b>${esc(st(s.state))}</b>${today ? `<span>${t("High {0}° · Low {1}°", Math.round(today.temperature), Math.round(today.templow ?? today.temperature))}</span>` : ""}</span>
|
||||
${c.sun !== false && sun ? `<span class="fc-sun"><span>${Icons.svg("sunrise")}${esc(hm(sun.next_rising))}</span><span>${Icons.svg("sunset")}${esc(hm(sun.next_setting))}</span></span>` : ""}</button>
|
||||
${hourly}${daily}${!cache.daily && !cache.hourly ? `<div class="empty">${t("Loading the forecast…")}</div>` : ""}</div>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
if (!ctx.ha.states[c.entity]) return;
|
||||
const cache = ctx.cache;
|
||||
if (cache.key === c.entity && Date.now() - cache.at < 30 * 60 * 1000) return;
|
||||
cache.key = c.entity; cache.at = Date.now();
|
||||
const [daily, hourly] = await Promise.all([ctx.ha.forecast(c.entity, "daily").catch(() => null), ctx.ha.forecast(c.entity, "hourly").catch(() => null)]);
|
||||
cache.daily = daily; cache.hourly = hourly;
|
||||
if (el.isConnected) ctx.rerender();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- timer
|
||||
// Counts down in the browser from finishes_at (ticked by app.js every second); presets start it with a duration.
|
||||
const hms = (secs) => { secs = Math.max(0, Math.round(secs)); const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), x = secs % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(x).padStart(2, "0")}` : `${m}:${String(x).padStart(2, "0")}`; };
|
||||
const toSecs = (v) => { const p = String(v || "0").split(":").map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p.length === 2 ? p[0] * 60 + p[1] : p[0]; };
|
||||
|
||||
W.timer = {
|
||||
label: "Timer", icon: "timer", size: [3, 3],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Timer", domains: ["timer"] },
|
||||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||
{ k: "presets", t: "text", l: "Quick starts in minutes", d: "5, 10, 15, 30" },
|
||||
{ k: "sound", t: "check", l: "Beep when done", d: true },
|
||||
],
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a timer");
|
||||
const a = s.attributes, e = esc(c.entity);
|
||||
const dur = toSecs(a.duration);
|
||||
// Spot the moment it runs out: active → idle around the finish time.
|
||||
if (cache.prev === "active" && s.state === "idle" && cache.finish && Date.now() >= cache.finish - 3000) {
|
||||
cache.doneUntil = Date.now() + 60000;
|
||||
if (c.sound !== false) window.HAFmt.beep?.();
|
||||
}
|
||||
cache.prev = s.state;
|
||||
cache.finish = a.finishes_at ? new Date(a.finishes_at).getTime() : null;
|
||||
const done = cache.doneUntil && Date.now() < cache.doneUntil && s.state === "idle";
|
||||
const left = s.state === "active" && a.finishes_at ? (new Date(a.finishes_at) - Date.now()) / 1000 : s.state === "paused" ? toSecs(a.remaining) : dur;
|
||||
const R = 44, C = 2 * Math.PI * R, frac = dur ? Math.max(0, Math.min(1, left / dur)) : 0;
|
||||
const presets = String(c.presets || "").split(/[,\s]+/).map(Number).filter((m) => m > 0).slice(0, 6);
|
||||
const ctl = (svc, ico, lbl, data) => `<button class="round wbtn" data-act="timer" data-e="${e}" data-v="${svc}" ${data ? `data-d="${data}"` : ""} aria-label="${esc(lbl)}" title="${esc(lbl)}">${Icons.svg(ico)}</button>`;
|
||||
return `<div class="tm ${s.state} ${done ? "done" : ""}">
|
||||
<div class="tm-name">${esc(friendly(s, c.name))}</div>
|
||||
<div class="tm-ring"><svg viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/><circle cx="50" cy="50" r="${R}" class="fill" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}" data-ring="${C.toFixed(1)}"/></svg>
|
||||
<span class="tm-time num" ${s.state === "active" && a.finishes_at ? `data-countdown="${esc(a.finishes_at)}" data-dur="${dur}"` : ""} aria-live="off">${done ? t("Done!") : hms(left)}</span></div>
|
||||
<div class="tm-ctrls">${done ? `<button class="chip wbtn" data-act="timer-ok" aria-pressed="true">${t("OK")}</button>`
|
||||
: s.state === "active" ? ctl("pause", "pause", t("Pause")) + ctl("cancel", "stop", t("Stop"))
|
||||
: s.state === "paused" ? ctl("start", "play", t("Resume")) + ctl("cancel", "stop", t("Stop"))
|
||||
: presets.map((m) => `<button class="chip wbtn" data-act="timer" data-e="${e}" data-v="start" data-d="${m}">${m} ${t("min")}</button>`).join("") + (dur ? ctl("start", "play", t("Start")) : "")}</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (ha.states[c.entity]?.state === "active" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- blinds / covers
|
||||
const coverPos = (s) => (s.attributes.current_position != null ? Number(s.attributes.current_position) : s.state === "open" ? 100 : s.state === "closed" ? 0 : 50);
|
||||
W.covers = {
|
||||
label: "Blinds", icon: "blinds", size: [4, 3],
|
||||
fields: [{ k: "title", t: "text", l: "Title", ph: "e.g. Living room" }, { k: "entities", t: "entities", l: "Blinds, curtains or shutters", domains: ["cover"] }],
|
||||
entities: (c) => c.entities || [],
|
||||
render(c, { ha }) {
|
||||
const ids = (c.entities || []).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return `<div class="empty">${t("Add one or more blinds in the inspector.")}</div>`;
|
||||
const btns = (e, s) => `<div class="cv-btns">
|
||||
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.open_cover" aria-label="${t("Open")}" ${coverPos(s) >= 100 ? "disabled" : ""}>${Icons.svg("arrow-up")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.stop_cover" aria-label="${t("Stop")}">${Icons.svg("stop")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${esc(e)}" data-v="cover.close_cover" aria-label="${t("Close")}" ${coverPos(s) <= 0 ? "disabled" : ""}>${Icons.svg("arrow-down")}</button></div>`;
|
||||
const slider = (e, s, kind = "pos") => {
|
||||
const v = kind === "pos" ? coverPos(s) : Number(s.attributes.current_tilt_position ?? 0);
|
||||
return `<input class="slider thin cv-slider" type="range" min="0" max="100" value="${v}" style="--pct:${v}%" data-act="cover-${kind}" data-e="${esc(e)}" aria-label="${esc(`${friendly(s)} ${kind === "pos" ? t("position") : t("tilt")}`)}">`;
|
||||
};
|
||||
// a window whose blind comes down as the cover closes
|
||||
const win = (s) => { const closed = 100 - coverPos(s); return `<div class="cv-win" aria-hidden="true"><div class="cv-blind" style="height:${closed}%"></div></div>`; };
|
||||
const moving = (s) => (["opening", "closing"].includes(s.state) ? ` · ${st(s.state)}` : "");
|
||||
if (ids.length === 1) {
|
||||
const e = ids[0], s = ha.states[e];
|
||||
return `<div class="cv one">${c.title ? `<div class="wt">${esc(c.title)}</div>` : ""}
|
||||
<div class="cv-main">${win(s)}<div class="cv-side"><span class="name">${esc(friendly(s))}</span><span class="cv-pct num">${coverPos(s)}%${esc(moving(s))}</span>${btns(e, s)}</div></div>
|
||||
${slider(e, s)}${s.attributes.current_tilt_position != null ? `<div class="cv-tilt"><span class="wt">${t("Tilt")}</span>${slider(e, s, "tilt")}</div>` : ""}</div>`;
|
||||
}
|
||||
return `<div class="cv">
|
||||
<div class="cv-head"><span class="name">${esc(c.title || t("Blinds"))}</span>
|
||||
<button class="chip wbtn" data-act="svc-many" data-v="cover.open_cover" data-ids="${esc(ids.join(","))}">${t("Open all")}</button>
|
||||
<button class="chip wbtn" data-act="svc-many" data-v="cover.close_cover" data-ids="${esc(ids.join(","))}">${t("Close all")}</button></div>
|
||||
<div class="cv-list">${ids.map((e) => { const s = ha.states[e]; return `<div class="cv-row">${win(s)}<div class="cv-row-main"><span class="cv-name">${esc(friendly(s))} <b class="num">${coverPos(s)}%</b>${esc(moving(s))}</span>${slider(e, s)}</div>${btns(e, s)}</div>`; }).join("")}</div></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- alarm
|
||||
const ALARM_FEATURES = [["armed_home", 1, "alarm_arm_home", "Home"], ["armed_away", 2, "alarm_arm_away", "Away"], ["armed_night", 4, "alarm_arm_night", "Night"], ["armed_vacation", 32, "alarm_arm_vacation", "Vacation"]];
|
||||
const isOpening = (s) => s.entity_id.startsWith("binary_sensor.") && ["door", "window", "opening", "garage_door"].includes(s.attributes.device_class);
|
||||
W.alarm = {
|
||||
label: "Alarm", icon: "shield", size: [4, 5],
|
||||
fields: [{ k: "entity", t: "entity", l: "Alarm panel", domains: ["alarm_control_panel"] }, { k: "openings", t: "entities", l: "Doors and windows to check (leave empty for all)", domains: ["binary_sensor"] }],
|
||||
entities: (c, ha) => [c.entity, ...(c.openings?.length ? c.openings : Object.values(ha?.states || {}).filter(isOpening).map((s) => s.entity_id))].filter(Boolean),
|
||||
render(c, { ha, cache }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "an alarm panel");
|
||||
const a = s.attributes, f = Number(a.supported_features) || 7, armed = s.state.startsWith("armed"), alarm = s.state === "triggered";
|
||||
const open = (c.openings?.length ? c.openings.map((e) => ha.states[e]).filter(Boolean) : Object.values(ha.states).filter(isOpening)).filter((o) => o.state === "on");
|
||||
const label = { disarmed: t("Off"), armed_home: t("On · home"), armed_away: t("On · away"), armed_night: t("On · night"), armed_vacation: t("On · vacation"), arming: t("Turning on…"), pending: t("Entry delay…"), triggered: t("Alarm!") }[s.state] || st(s.state);
|
||||
const code = cache.code || "";
|
||||
const needsCode = !!a.code_format;
|
||||
const modes = ALARM_FEATURES.filter(([, bit]) => f & bit).map(([st_, , svc, lbl]) => `<button class="al-mode wbtn ${s.state === st_ ? "on" : ""}" data-act="alarm" data-e="${esc(c.entity)}" data-v="${svc}" aria-pressed="${s.state === st_}">${esc(t(lbl))}</button>`).join("");
|
||||
const keys = needsCode ? `<div class="al-code" aria-live="polite">${code ? "•".repeat(code.length) : `<span class="wt">${t("Enter the code")}</span>`}</div>
|
||||
<div class="al-keys">${["1", "2", "3", "4", "5", "6", "7", "8", "9", "C", "0", "⌫"].map((k) => `<button class="al-key wbtn" data-act="alarm-key" data-v="${k}" aria-label="${k === "C" ? t("Clear") : k === "⌫" ? t("Delete last digit") : k}">${k}</button>`).join("")}</div>` : "";
|
||||
return `<div class="al state-${esc(s.state)}">
|
||||
<div class="al-head"><span class="al-shield">${Icons.svg("shield")}</span><span class="tile-text"><span class="name">${esc(friendly(s))}</span><span class="al-state">${esc(label)}</span></span></div>
|
||||
${open.length && !armed ? `<button class="al-open wbtn" data-act="more" data-e="${esc(open[0].entity_id)}">${Icons.svg("door-open")}<span>${esc(t("{0} still open: {1}", open.length, open.map((o) => friendly(o)).join(", ")))}</span></button>` : ""}
|
||||
${keys}
|
||||
<div class="al-modes">${armed || alarm || s.state === "arming" || s.state === "pending" ? `<button class="al-mode off wbtn" data-act="alarm" data-e="${esc(c.entity)}" data-v="alarm_disarm">${t("Turn off")}</button>` : modes}</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (ha.states[c.entity]?.state === "triggered" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- notifications
|
||||
// Home Assistant's notifications (kept up to date by a subscription in app.js) and available updates.
|
||||
const mdLite = (txt) => esc(txt).replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]*)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>').replace(/\n/g, "<br>");
|
||||
W.notifications = {
|
||||
label: "Notifications", icon: "bell", size: [4, 4],
|
||||
fields: [{ k: "notes", t: "check", l: "Show notifications", d: true }, { k: "updates", t: "check", l: "Show available updates", d: true }],
|
||||
entities: (c, ha) => ["__notifications", ...Object.keys(ha?.states || {}).filter((e) => e.startsWith("update."))],
|
||||
render(c, { ha }) {
|
||||
const notes = c.notes !== false ? Object.values(ha.notifications || {}).sort((x, y) => String(y.created_at).localeCompare(String(x.created_at))) : [];
|
||||
const updates = c.updates !== false ? Object.values(ha.states).filter((s) => s.entity_id.startsWith("update.") && s.state === "on") : [];
|
||||
const count = notes.length + updates.length;
|
||||
const items = [
|
||||
...updates.map((u) => `<div class="nt-item update"><span class="nt-ico">${Icons.svg("sparkles")}</span><div class="nt-body"><b>${esc(u.attributes.title || friendly(u))}</b>
|
||||
<span class="wt">${esc(t("{0} → {1}", u.attributes.installed_version || "?", u.attributes.latest_version || "?"))}</span>
|
||||
${u.attributes.release_url ? `<a href="${esc(u.attributes.release_url)}" target="_blank" rel="noopener" class="nt-link">${t("What’s new")}</a>` : ""}</div>
|
||||
<button class="chip wbtn" data-act="update-install" data-e="${esc(u.entity_id)}">${t("Install")}</button></div>`),
|
||||
...notes.map((n) => `<div class="nt-item"><span class="nt-ico">${Icons.svg("bell")}</span><div class="nt-body">${n.title ? `<b>${esc(n.title)}</b>` : ""}<span class="nt-msg">${mdLite(n.message || "")}</span>
|
||||
<span class="nt-time">${esc(relTime(n.created_at))}</span></div>
|
||||
<button class="nt-x wbtn" data-act="note-dismiss" data-v="${esc(n.notification_id)}" aria-label="${t("Dismiss")}">${Icons.svg("close")}</button></div>`),
|
||||
].join("");
|
||||
return `<div class="nt"><div class="nt-head"><span class="name">${t("Notifications")}</span>${count ? `<span class="todo-count num">${count}</span>` : ""}
|
||||
${notes.length > 1 ? `<button class="chip wbtn" data-act="note-dismiss-all">${t("Dismiss all")}</button>` : ""}</div>
|
||||
<div class="nt-list">${items || `<div class="nt-empty">${Icons.svg("check")}<span>${t("All caught up")}</span></div>`}</div></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- energy
|
||||
// Live power flow between solar, grid, battery and home. Lines are as thick and as fast as the power.
|
||||
function watts(s) {
|
||||
@@ -848,11 +1134,23 @@
|
||||
// ---------------------------------------------------------------- car
|
||||
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
|
||||
|
||||
// Built around a round gauge, like an instrument cluster: charge as an arc with the charge limit marked,
|
||||
// the range large in the middle, and the car in front. Sizes adapt through container queries.
|
||||
const CAR_ART = `<svg class="car-art2" viewBox="0 0 200 78" aria-hidden="true">
|
||||
<defs><linearGradient id="carBody" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--car-top)"/><stop offset="1" stop-color="var(--car-bottom)"/></linearGradient>
|
||||
<linearGradient id="carGlass" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#cfe3ff" stop-opacity=".55"/><stop offset="1" stop-color="#7a8cab" stop-opacity=".35"/></linearGradient></defs>
|
||||
<ellipse cx="100" cy="70" rx="86" ry="5" class="car-shadow"/>
|
||||
<path class="car-body2" d="M14 56c0-6 4-9 12-11l30-6c10-10 22-15 40-16h24c14 0 26 5 36 13l14 5c12 2 18 6 19 12v5c0 3-2 4-5 4H16c-1.5 0-2-2-2-6z"/>
|
||||
<path class="car-glass2" d="M64 39c8-8 18-12 32-12h12v12zM112 27h8c11 0 20 4 28 11l-36 1z"/>
|
||||
<path class="car-line" d="M30 50h150"/><circle cx="182" cy="52" r="2.2" class="car-light"/>
|
||||
<circle cx="52" cy="62" r="12" class="car-tire"/><circle cx="52" cy="62" r="6" class="car-rim"/>
|
||||
<circle cx="154" cy="62" r="12" class="car-tire"/><circle cx="154" cy="62" r="6" class="car-rim"/></svg>`;
|
||||
|
||||
W.car = {
|
||||
label: "Car", icon: "car", size: [3, 4],
|
||||
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: "image", t: "text", l: "Car image URL (optional, a side view with a transparent background looks best)", 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"] },
|
||||
@@ -860,72 +1158,70 @@
|
||||
{ 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: "toFull", t: "entity", l: "Time until full (minutes or end time)", domains: ["sensor"] },
|
||||
{ k: "chargeSwitch", t: "entity", l: "Start/stop charging (switch)", domains: ["switch", "button"] },
|
||||
{ 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),
|
||||
// Layout follows the card's size (container queries in style.css): designed around 3×4,
|
||||
// a compact strip when short, and the car beside the numbers when wide.
|
||||
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "toFull", "chargeSwitch", "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");
|
||||
const S = (k) => (c[k] ? ha.states[c[k]] : null);
|
||||
const level = S("level"), range = S("range"), lock = S("lock"), clim = S("climate"), loc = S("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"), odo = st_("odometer");
|
||||
const charging = truthy(st_("charging")) || (!c.charging && power && Number(power.state) > 0);
|
||||
const power = S("power"), plug = S("plug"), limit = S("limit"), odo = S("odometer"), toFull = S("toFull"), chg = S("chargeSwitch");
|
||||
const charging = truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on");
|
||||
const plugged = charging || truthy(plug);
|
||||
const low = pct != null && pct <= 20;
|
||||
const fuel = c.kind === "fuel";
|
||||
const fuel = c.kind === "fuel", low = pct != null && pct <= 20;
|
||||
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
|
||||
|
||||
let status;
|
||||
// status line and the "full at" time
|
||||
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 = "";
|
||||
let fullAt = "";
|
||||
if (charging && toFull) {
|
||||
const v = toFull.state, mins = /^\d{4}-\d{2}-\d{2}T/.test(v) ? (new Date(v) - Date.now()) / 60000 : Number(v) * (String(toFull.attributes.unit_of_measurement || "min").toLowerCase().startsWith("h") ? 60 : 1);
|
||||
if (isFinite(mins) && mins > 0) fullAt = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }));
|
||||
}
|
||||
|
||||
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
|
||||
// Drawn car for when there's no photo: a side view, with a glow and a bolt while charging.
|
||||
const drawn = `<svg class="car-art" viewBox="0 0 200 84" aria-hidden="true">
|
||||
<path class="car-body" d="M10 60c1-10 9-15 22-17l26-15c7-4 15-6 24-6h40c10 0 18 3 25 9l15 12c13 1 24 4 28 11l2 8c0 4-2 6-6 6H12c-3 0-4-3-2-8z"/>
|
||||
<path class="car-glass" d="M64 31c6-3 12-4 19-4h16v18H52zM105 27h17c8 0 14 2 20 7l9 11h-46z"/>
|
||||
<circle class="car-wheel" cx="46" cy="68" r="13"/><circle class="car-wheel" cx="154" cy="68" r="13"/>
|
||||
<circle class="car-hub" cx="46" cy="68" r="5"/><circle class="car-hub" cx="154" cy="68" r="5"/></svg>`;
|
||||
const visual = `<div class="car-visual ${charging ? "is-charging" : ""}">${c.image ? `<img class="car-photo" src="${esc(c.image)}" alt="">` : drawn}
|
||||
${charging ? `<span class="car-bolt" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>`;
|
||||
// the gauge: 240° arc, level filled, charge limit as a tick
|
||||
const A0 = Math.PI * (150 / 180), SPAN = Math.PI * (240 / 180), R = 80;
|
||||
const ang = (p) => A0 + SPAN * Math.max(0, Math.min(1, p / 100));
|
||||
const pt = (p, r = R) => [100 + r * Math.cos(ang(p)), 100 + r * Math.sin(ang(p))];
|
||||
const arc = (p0, p1) => { const [x0, y0] = pt(p0), [x1, y1] = pt(p1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(p1) - ang(p0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
|
||||
const tick = limPct != null && !fuel ? (() => { const [x0, y0] = pt(limPct, R - 11), [x1, y1] = pt(limPct, R + 11); return `<line x1="${x0.toFixed(1)}" y1="${y0.toFixed(1)}" x2="${x1.toFixed(1)}" y2="${y1.toFixed(1)}" class="cg-limit"/>`; })() : "";
|
||||
const gauge = `<div class="cg ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct ?? 0}" aria-label="${esc(fuel ? t("Fuel level") : t("Battery level"))}">
|
||||
<svg viewBox="0 0 200 200" aria-hidden="true"><path d="${arc(0, 100)}" class="cg-track"/>${pct ? `<path d="${arc(0, pct)}" class="cg-fill"/>` : ""}${tick}</svg>
|
||||
<div class="cg-center">${range ? `<span class="cg-range num">${esc(fmtNum(range.state))}<small>${esc(range.attributes.unit_of_measurement || "km")}</small></span>` : ""}
|
||||
${pct != null ? `<span class="cg-pct num">${charging ? Icons.svg("bolt", "inline") : ""}${pct}%${low ? ` · ${t("Low")}` : ""}</span>` : ""}</div>
|
||||
<div class="cg-car">${c.image ? `<img src="${esc(c.image)}" alt="">` : CAR_ART}</div></div>`;
|
||||
|
||||
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="${fuel ? t("Fuel level") : t("Battery level")}">
|
||||
<span class="car-fill" style="width:${pct}%"></span>
|
||||
${limPct != null && !fuel ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
|
||||
</div>`;
|
||||
const btn = (act, eid, on, ico, label, sub) => `<button class="cb wbtn" data-act="${act}" data-e="${esc(eid)}" aria-label="${esc(sub ? `${label}, ${sub}` : label)}" title="${esc(label)}" ${act === "toggle" ? `aria-pressed="${on}"` : ""}>
|
||||
<span class="cb-ico">${Icons.svg(ico)}</span><span class="cb-label">${esc(label)}</span>${sub ? `<span class="cb-sub num">${esc(sub)}</span>` : ""}</button>`;
|
||||
const buttons = [];
|
||||
if (lock) { const locked = lock.state === "locked"; buttons.push(btn("toggle", c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
|
||||
if (clim) {
|
||||
const on = isOn(clim) || clim.state === "on", inside = clim.attributes.current_temperature;
|
||||
buttons.push(btn("toggle", c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"), inside != null ? `${inside}° ${t("inside")}` : ""));
|
||||
}
|
||||
if (chg && !fuel) {
|
||||
const isBtn = domainOf(c.chargeSwitch) === "button";
|
||||
buttons.push(isBtn ? `<button class="cb wbtn" data-act="svc" data-e="${esc(c.chargeSwitch)}" data-v="button.press"><span class="cb-ico">${Icons.svg("bolt")}</span><span class="cb-label">${esc(friendly(chg))}</span></button>`
|
||||
: btn("toggle", c.chargeSwitch, charging, "bolt", charging ? t("Stop charging") : t("Start charging")));
|
||||
}
|
||||
if (loc) buttons.push(btn("more", c.location, false, "pin", fmtState(loc)));
|
||||
|
||||
const action = (eid, on, ico, label) => `<button class="car-btn wbtn" data-act="${eid === c.location ? "more" : "toggle"}" data-e="${esc(eid)}" ${eid === c.location ? "" : `aria-pressed="${on}"`}>
|
||||
<span class="car-btn-ico">${Icons.svg(ico)}</span><span class="car-btn-label">${esc(label)}</span></button>`;
|
||||
const actions = [];
|
||||
if (lock) { const locked = lock.state === "locked"; actions.push(action(c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
|
||||
if (clim) { const on = isOn(clim) || clim.state === "on"; actions.push(action(c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"))); }
|
||||
if (loc) actions.push(action(c.location, false, "pin", fmtState(loc)));
|
||||
const chips = [fullAt && `<span class="cchip">${Icons.svg("clock")}${esc(fullAt)}</span>`, odo && `<span class="cchip">${Icons.svg("road")}${esc(fmtState(odo))}</span>`].filter(Boolean).join("");
|
||||
|
||||
const stat = (label, value) => `<div class="car-stat"><span class="car-stat-v num">${esc(value)}</span><span class="car-stat-l">${esc(label)}</span></div>`;
|
||||
const stats = [
|
||||
odo && stat(t("Odometer"), fmtState(odo)),
|
||||
limPct != null && !fuel && stat(t("Charge limit"), `${limPct}%`),
|
||||
charging && power && stat(t("Charging power"), fmtState(power)),
|
||||
].filter(Boolean).join("");
|
||||
|
||||
return `<div class="car2 ${c.image ? "has-photo" : ""}">
|
||||
<div class="car-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car-status ${charging ? "charging" : ""}">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</span>` : ""}</div>
|
||||
${visual}
|
||||
<div class="car-info">
|
||||
<div class="car-nums">${pct != null ? `<span class="car-pct num">${pct}<span class="unit">%</span></span>` : ""}
|
||||
${range ? `<span class="car-range num">${esc(fmtState(range))}</span>` : ""}${low ? `<span class="level">${t("Low")}</span>` : ""}</div>
|
||||
${bar}
|
||||
${stats ? `<div class="car-stats">${stats}</div>` : ""}
|
||||
${actions.length ? `<div class="car-actions2">${actions.join("")}</div>` : ""}
|
||||
</div></div>`;
|
||||
return `<div class="car3 ${charging ? "is-charging" : ""}">
|
||||
<div class="car3-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car3-status ${charging ? "charging" : ""}">${esc(status)}</span>` : ""}</div>
|
||||
${gauge}
|
||||
<div class="car3-info">${chips ? `<div class="cchips">${chips}</div>` : ""}${buttons.length ? `<div class="cbs">${buttons.join("")}</div>` : ""}</div>
|
||||
</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1171,7 +1467,13 @@
|
||||
// ctx.renderChildren fills them using the normal widget renderer.
|
||||
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
|
||||
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}" ${Number(ch.cfg?._width) > 0 ? `style="--own-w:${Number(ch.cfg._width)}px"` : ""}></div>`;
|
||||
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
|
||||
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? esc(c.emptyText || t("None of these cards are showing right now.")) : t("Add cards in the inspector.")}</div></div>`;
|
||||
// What a container shows when all its cards are hidden by their conditions: a text of your own, or nothing at all.
|
||||
const EMPTY_FIELDS = [
|
||||
{ k: "hideEmpty", t: "check", l: "Hide this card when none of its cards are showing", d: false },
|
||||
{ k: "emptyText", t: "text", l: "Text when none of its cards are showing", ph: "None of these cards are showing right now.", when: (c) => !c.hideEmpty },
|
||||
];
|
||||
const containerHidden = (c, ctx) => !!c.hideEmpty && (c.children || []).length > 0 && !(c.children || []).some((ch) => ctx.isVisible(ch));
|
||||
|
||||
W.hscroll = {
|
||||
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
|
||||
@@ -1180,7 +1482,9 @@
|
||||
{ k: "widthMode", t: "seg", l: "Card width", o: ["pixels", "per-view"], d: "pixels" },
|
||||
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260, when: (c) => c.widthMode !== "per-view" },
|
||||
{ k: "perView", t: "number", l: "Cards visible at once", d: 2.5, ph: "e.g. 1, 2 or 2.5", when: (c) => c.widthMode === "per-view" },
|
||||
...EMPTY_FIELDS,
|
||||
],
|
||||
autoHide: containerHidden,
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
@@ -1212,7 +1516,9 @@
|
||||
fields: [
|
||||
{ k: "autoplay", t: "number", l: "Next card every (seconds)", d: 0, ph: "0 = off" },
|
||||
{ k: "dots", t: "check", l: "Show page dots while swiping", d: true },
|
||||
...EMPTY_FIELDS,
|
||||
],
|
||||
autoHide: containerHidden,
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
@@ -1328,5 +1634,12 @@
|
||||
};
|
||||
|
||||
window.Widgets = W;
|
||||
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
|
||||
function beep() {
|
||||
try {
|
||||
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
||||
[0, 0.35, 0.7].forEach((t0) => { const o = ac.createOscillator(), g = ac.createGain(); o.frequency.value = 880; g.gain.setValueAtTime(0.0001, ac.currentTime + t0);
|
||||
g.gain.exponentialRampToValueAtTime(0.3, ac.currentTime + t0 + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + t0 + 0.25); o.connect(g).connect(ac.destination); o.start(ac.currentTime + t0); o.stop(ac.currentTime + t0 + 0.3); });
|
||||
} catch { /* no audio */ }
|
||||
}
|
||||
window.HAFmt = { slideshow, beep, hms, esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap, lightTint, canColor, canTemp, WHITES, COLORS };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user