mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
feat v1.42.0: room settings — the third settings tier
- four-tier principle fixed in ARCHITECTURE: global > space > room >
device, specific overrides general, unset inherits
- room.settings { fill_mode, temp_source, hum_source } with backend
schema; pure roomFillModeOf + sourceValue (+2 test suites, 114)
- gear on room cards in the Plan editor opens Room settings: rename,
re-area (current area included in the list), fill override (may opt
out of glow darkness), explicit temp/hum source with a searchable
device+entity dropdown; the same section in the creation dialog
- source feeds the room card, tooltip and temp fill; works for rooms
without an HA area (user-feedback case #1)
- smoke_room_settings.mjs (10 checks); TESTING/CHANGELOG/ARCHITECTURE
same-commit
This commit is contained in:
+230
-27
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls,
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf,
|
||||
snapToWall, openingAmount,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats } from './devices';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from './devices';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.41.2';
|
||||
const CARD_VERSION = '1.42.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -157,6 +157,12 @@ class HouseplanCard extends LitElement {
|
||||
private _areaSel = '';
|
||||
private _nameSel = '';
|
||||
private _roomDialog = false;
|
||||
private _roomEditId: string | null = null; // gear on a room card (edit mode)
|
||||
private _roomFill: '' | 'none' | 'lqi' | 'light' | 'temp' = ''; // '' = inherit
|
||||
private _roomTempSrc = ''; // '' = average
|
||||
private _roomHumSrc = '';
|
||||
private _roomSrcOpen: 'temp' | 'hum' | null = null;
|
||||
private _roomSrcFilter = '';
|
||||
// plan zoom/pan (zoom is saved per space, locally)
|
||||
private _zoom = 1;
|
||||
private _view: { x: number; y: number; w: number; h: number } | null = null; // current SVG viewBox (vb coordinates)
|
||||
@@ -289,6 +295,12 @@ class HouseplanCard extends LitElement {
|
||||
_areaSel: { state: true },
|
||||
_nameSel: { state: true },
|
||||
_roomDialog: { state: true },
|
||||
_roomEditId: { state: true },
|
||||
_roomFill: { state: true },
|
||||
_roomTempSrc: { state: true },
|
||||
_roomHumSrc: { state: true },
|
||||
_roomSrcOpen: { state: true },
|
||||
_roomSrcFilter: { state: true },
|
||||
_spaceDialog: { state: true },
|
||||
_infoCard: { state: true },
|
||||
_rulesDialog: { state: true },
|
||||
@@ -482,6 +494,7 @@ class HouseplanCard extends LitElement {
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
open_to: r.open_to || undefined,
|
||||
settings: r.settings || undefined,
|
||||
x: r.x != null ? r.x * NORM_W : undefined,
|
||||
y: r.y != null ? r.y * H : undefined,
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
@@ -1464,6 +1477,7 @@ class HouseplanCard extends LitElement {
|
||||
this._cursorPt = null;
|
||||
this._nameSel = '';
|
||||
this._areaSel = '';
|
||||
this._resetRoomDialogFields();
|
||||
this._roomDialog = true;
|
||||
return;
|
||||
}
|
||||
@@ -2065,6 +2079,7 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(this._t('toast.split_bad_cut'));
|
||||
return;
|
||||
}
|
||||
this._resetRoomDialogFields();
|
||||
// the bigger part stays the room it was — name, area and devices go with it
|
||||
const [p1, p2] = parts;
|
||||
const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2;
|
||||
@@ -2148,6 +2163,7 @@ class HouseplanCard extends LitElement {
|
||||
name: this._nameSel || areaName || this._t('room.default_name'),
|
||||
area: this._areaSel || null,
|
||||
poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]),
|
||||
...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}),
|
||||
});
|
||||
this._saveConfig();
|
||||
this._path = [];
|
||||
@@ -2199,6 +2215,12 @@ class HouseplanCard extends LitElement {
|
||||
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
|
||||
private _roomDialogCancel(): void {
|
||||
this._roomDialog = false;
|
||||
if (this._roomEditId) {
|
||||
this._roomEditId = null;
|
||||
this._nameSel = '';
|
||||
this._areaSel = '';
|
||||
return;
|
||||
}
|
||||
if (this._pendingSplit) {
|
||||
// nothing was applied yet — drop the cut entirely, the room stays whole
|
||||
this._pendingSplit = null;
|
||||
@@ -3309,20 +3331,25 @@ class HouseplanCard extends LitElement {
|
||||
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||
cls += ' picked';
|
||||
let style = '';
|
||||
if (!this._markup && (disp.showBorders || disp.fill !== 'none')) {
|
||||
const effFill = roomFillModeOf(disp.fill, r);
|
||||
if (!this._markup && (disp.showBorders || effFill !== 'none')) {
|
||||
cls += ' styled';
|
||||
const st: string[] = [];
|
||||
// keep the stroke colour even when borders are hidden, so hover can reveal it
|
||||
st.push(`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`);
|
||||
const fillC = disp.fill === 'glow'
|
||||
// glow: uniform darkness over EVERY room (area or not, lit or not)
|
||||
const fillC = effFill === 'glow'
|
||||
// glow: uniform darkness (a room override may opt OUT of it)
|
||||
? this._fillColors.glow_base
|
||||
: effFill === 'temp'
|
||||
// temp works without an HA area when a tier-3 source is set
|
||||
? roomFillStyle('temp', null, 'none', this._roomTemp(r),
|
||||
disp.tempMin, disp.tempMax, this._fillColors)
|
||||
: r.area
|
||||
? roomFillStyle(
|
||||
disp.fill,
|
||||
disp.fill === 'lqi' ? this._roomLqi(r.area) : null,
|
||||
disp.fill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
|
||||
disp.fill === 'temp' ? areaTemp(this.hass, this._devices, r.area) : null,
|
||||
effFill,
|
||||
effFill === 'lqi' ? this._roomLqi(r.area) : null,
|
||||
effFill === 'light' ? areaLights(this.hass, this._devices, r.area) : 'none',
|
||||
null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
this._fillColors,
|
||||
@@ -3337,7 +3364,7 @@ class HouseplanCard extends LitElement {
|
||||
const tip = (e: MouseEvent) =>
|
||||
this._showTip(e, r.name, this._t('tip.room'),
|
||||
showLqi ? this._roomLqi(r.area) : null,
|
||||
r.area ? areaTemp(this.hass, this._devices, r.area) : null);
|
||||
this._roomTemp(r));
|
||||
const label = !space.bg && !disp.showNames && !this._markup;
|
||||
const c = this._roomCenter(r);
|
||||
// open boundaries: this room's solid stroke must not run beneath
|
||||
@@ -3514,6 +3541,107 @@ class HouseplanCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Room temperature honouring the tier-3 source override. */
|
||||
private _roomTemp(r: RoomCfg): number | null {
|
||||
const src = r.settings?.temp_source;
|
||||
if (src) return sourceValue(this.hass, src, 'temp');
|
||||
return r.area ? areaTemp(this.hass, this._devices, r.area) : null;
|
||||
}
|
||||
|
||||
/** Room humidity honouring the tier-3 source override. */
|
||||
private _roomHum(r: RoomCfg): number | null {
|
||||
const src = r.settings?.hum_source;
|
||||
if (src) return sourceValue(this.hass, src, 'hum');
|
||||
return r.area ? areaHum(this.hass, this._devices, r.area) : null;
|
||||
}
|
||||
|
||||
private _resetRoomDialogFields(): void {
|
||||
this._roomEditId = null;
|
||||
this._roomFill = '';
|
||||
this._roomTempSrc = '';
|
||||
this._roomHumSrc = '';
|
||||
this._roomSrcOpen = null;
|
||||
this._roomSrcFilter = '';
|
||||
}
|
||||
|
||||
/** Open the room dialog for an EXISTING room (the gear on its card). */
|
||||
private _openRoomEdit(r: RoomCfg): void {
|
||||
if (!r.id) return;
|
||||
this._roomEditId = r.id;
|
||||
this._nameSel = r.name || '';
|
||||
this._areaSel = r.area || '';
|
||||
this._roomFill = (r.settings?.fill_mode as any) || '';
|
||||
this._roomTempSrc = r.settings?.temp_source || '';
|
||||
this._roomHumSrc = r.settings?.hum_source || '';
|
||||
this._roomSrcOpen = null;
|
||||
this._roomSrcFilter = '';
|
||||
this._roomDialog = true;
|
||||
}
|
||||
|
||||
/** Collect the room settings object from the dialog state (null = all inherited). */
|
||||
private _roomSettingsFromDialog(): RoomCfg['settings'] {
|
||||
const st: any = {};
|
||||
if (this._roomFill) st.fill_mode = this._roomFill;
|
||||
if (this._roomTempSrc) st.temp_source = this._roomTempSrc;
|
||||
if (this._roomHumSrc) st.hum_source = this._roomHumSrc;
|
||||
return Object.keys(st).length ? st : null;
|
||||
}
|
||||
|
||||
/** Save the room edited via the gear (name, area, tier-3 settings). */
|
||||
private _saveRoomEdit(): void {
|
||||
const sp = this._curSpaceCfg;
|
||||
const room = sp?.rooms.find((x: any) => x.id === this._roomEditId);
|
||||
if (!room) {
|
||||
this._roomDialog = false;
|
||||
this._roomEditId = null;
|
||||
return;
|
||||
}
|
||||
room.name = this._nameSel.trim() || room.name;
|
||||
room.area = this._areaSel || null;
|
||||
const st = this._roomSettingsFromDialog();
|
||||
if (st) room.settings = st;
|
||||
else delete room.settings;
|
||||
this._saveConfig();
|
||||
this._roomDialog = false;
|
||||
this._roomEditId = null;
|
||||
this._nameSel = '';
|
||||
this._areaSel = '';
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
this._showToast(this._t('toast.room_updated'));
|
||||
}
|
||||
|
||||
/** Devices + sensor entities for the measurement-source picker. */
|
||||
private _roomSrcCandidates(): { value: string; label: string; sub: string }[] {
|
||||
const h = this.hass;
|
||||
const q = this._roomSrcFilter.trim().toLowerCase();
|
||||
const list: { value: string; label: string; sub: string }[] = [];
|
||||
for (const dev of Object.values<any>(h.devices)) {
|
||||
if (dev.entry_type === 'service') continue;
|
||||
const name = (dev.name_by_user || dev.name || dev.id).trim();
|
||||
if (q && !name.toLowerCase().includes(q)) continue;
|
||||
list.push({ value: 'device:' + dev.id, label: name, sub: dev.model || this._t('marker.sub_device') });
|
||||
}
|
||||
for (const [eid, reg] of Object.entries<any>(h.entities)) {
|
||||
if (!eid.startsWith('sensor.') || reg.hidden) continue;
|
||||
const label = reg.name || h.states[eid]?.attributes?.friendly_name || eid;
|
||||
if (q && !(label + ' ' + eid).toLowerCase().includes(q)) continue;
|
||||
list.push({ value: 'entity:' + eid, label, sub: eid });
|
||||
}
|
||||
list.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return list.slice(0, 200);
|
||||
}
|
||||
|
||||
/** Human label of a picked measurement source. */
|
||||
private _roomSrcLabel(src: string): string {
|
||||
const i = src.indexOf(':');
|
||||
const k = src.slice(0, i);
|
||||
const ref = src.slice(i + 1);
|
||||
if (k === 'device') return this.hass.devices[ref]?.name_by_user || this.hass.devices[ref]?.name || ref;
|
||||
return this.hass.entities[ref]?.name || this.hass.states[ref]?.attributes?.friendly_name || ref;
|
||||
}
|
||||
|
||||
/** Saved label position (layout key rl_<roomId>) or the room center. */
|
||||
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
|
||||
const saved = this._layout['rl_' + (r.id || '')];
|
||||
@@ -3623,20 +3751,20 @@ class HouseplanCard extends LitElement {
|
||||
const k = this._labelScale(r);
|
||||
// optional metrics row (needs an HA area; sub-area rooms show the name only)
|
||||
const rows: TemplateResult[] = [];
|
||||
if (r.area && !this._markup) {
|
||||
if ((r.area || r.settings?.temp_source || r.settings?.hum_source) && !this._markup) {
|
||||
if (disp.labelTemp) {
|
||||
const t = areaTemp(this.hass, this._devices, r.area);
|
||||
const t = this._roomTemp(r);
|
||||
if (t != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:thermometer"></ha-icon>${t}°</span>`);
|
||||
}
|
||||
if (disp.labelHum) {
|
||||
const hm = areaHum(this.hass, this._devices, r.area);
|
||||
const hm = this._roomHum(r);
|
||||
if (hm != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:water-percent"></ha-icon>${hm}%</span>`);
|
||||
}
|
||||
if (disp.labelLqi) {
|
||||
if (disp.labelLqi && r.area) {
|
||||
const l = this._roomLqi(r.area);
|
||||
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
|
||||
}
|
||||
if (disp.labelLight) {
|
||||
if (disp.labelLight && r.area) {
|
||||
const ls = areaLightStats(this.hass, this._devices, r.area);
|
||||
if (ls) {
|
||||
const txt = ls.on === 0
|
||||
@@ -3654,7 +3782,12 @@ class HouseplanCard extends LitElement {
|
||||
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
|
||||
@pointerup=${() => this._labelUp(r)}
|
||||
@pointercancel=${() => this._labelUp(r)}
|
||||
><span class="rlname">${r.name}${!this._markup && r.area
|
||||
><span class="rlname">${this._markup && r.id
|
||||
? html`<ha-icon class="rlgear" icon="mdi:cog-outline"
|
||||
title=${this._t('room.settings_title')}
|
||||
@pointerdown=${(e: Event) => e.stopPropagation()}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}></ha-icon>`
|
||||
: nothing}${r.name}${!this._markup && r.area
|
||||
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
|
||||
title=${this._t('room.open_area')}
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
|
||||
@@ -4606,11 +4739,65 @@ class HouseplanCard extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** One measurement-source control (average vs an explicit device/entity). */
|
||||
private _renderRoomSource(kind: 'temp' | 'hum'): TemplateResult {
|
||||
const val = kind === 'temp' ? this._roomTempSrc : this._roomHumSrc;
|
||||
const setVal = (v: string) => {
|
||||
if (kind === 'temp') this._roomTempSrc = v;
|
||||
else this._roomHumSrc = v;
|
||||
this.requestUpdate();
|
||||
};
|
||||
const open = this._roomSrcOpen === kind;
|
||||
return html`
|
||||
<label>${this._t(kind === 'temp' ? 'room.temp_src_label' : 'room.hum_src_label')}</label>
|
||||
<label class="srcrow">
|
||||
<input type="radio" name="rsrc-${kind}" .checked=${!val}
|
||||
@change=${() => { setVal(''); this._roomSrcOpen = null; }} />
|
||||
<span>${this._t('room.src_average')}</span>
|
||||
</label>
|
||||
<label class="srcrow">
|
||||
<input type="radio" name="rsrc-${kind}" .checked=${!!val}
|
||||
@change=${() => { this._roomSrcOpen = kind; this._roomSrcFilter = ''; this.requestUpdate(); }} />
|
||||
<span>${this._t('room.src_pick')}</span>
|
||||
</label>
|
||||
${val || open
|
||||
? html`<button class="dropbtn ${open ? 'open' : ''}"
|
||||
@click=${() => { this._roomSrcOpen = open ? null : kind; this._roomSrcFilter = ''; }}>
|
||||
${val
|
||||
? html`<b>${this._roomSrcLabel(val)}</b><span class="ref">${val}</span>`
|
||||
: html`<span class="muted">${this._t('room.src_ph')}</span>`}
|
||||
<ha-icon icon=${open ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
|
||||
</button>
|
||||
${open
|
||||
? html`<div class="droppanel">
|
||||
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
|
||||
.value=${this._roomSrcFilter}
|
||||
@input=${(e: Event) => { this._roomSrcFilter = (e.target as HTMLInputElement).value; this.requestUpdate(); }} />
|
||||
<div class="candlist">
|
||||
${this._roomSrcCandidates().map(
|
||||
(c) => html`<div class="cand ${c.value === val ? 'sel' : ''}"
|
||||
@click=${() => { setVal(c.value); this._roomSrcOpen = null; }}>
|
||||
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
private _renderRoomDialog(): TemplateResult {
|
||||
const areas = this._freeAreas;
|
||||
const edit = !!this._roomEditId;
|
||||
// the free-areas list must include the edited room's CURRENT area
|
||||
const areas = [...this._freeAreas];
|
||||
if (edit && this._areaSel && !areas.some((a) => a.area_id === this._areaSel)) {
|
||||
const cur = this.hass.areas[this._areaSel];
|
||||
if (cur) areas.unshift(cur);
|
||||
}
|
||||
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>${this._t('room.new')}</div>
|
||||
<div class="hd"><ha-icon icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'}></ha-icon>
|
||||
${edit ? this._t('room.settings_title') : this._t('room.new')}</div>
|
||||
<div class="body">
|
||||
<label>${this._t('room.name_label')}</label>
|
||||
<input class="namein" type="text" placeholder=${this._t('room.name_ph')}
|
||||
@@ -4629,18 +4816,34 @@ class HouseplanCard extends LitElement {
|
||||
(a) => html`<option value=${a.area_id} ?selected=${a.area_id === this._areaSel}>${a.name}</option>`,
|
||||
)}
|
||||
</select>
|
||||
|
||||
<label class="dispsection">${this._t('room.settings_section')}</label>
|
||||
<label>${this._t('room.fill_label')}</label>
|
||||
${([['', 'fill.inherit'], ['none', 'fill.none'], ['lqi', 'fill.lqi'], ['light', 'fill.light'], ['temp', 'fill.temp']] as const).map(
|
||||
([v, k]) => html`<label class="srcrow inline">
|
||||
<input type="radio" name="rfill" .checked=${this._roomFill === v}
|
||||
@change=${() => { this._roomFill = v as any; this.requestUpdate(); }} />
|
||||
<span>${this._t(k as any)}</span>
|
||||
</label>`,
|
||||
)}
|
||||
${this._renderRoomSource('temp')}
|
||||
${this._renderRoomSource('hum')}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
|
||||
title=${this._t('title.no_area_room')}>
|
||||
${this._t('btn.no_area')}
|
||||
</button>
|
||||
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
|
||||
title=${!this._areaSel ? this._t('title.choose_area') : ''}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||
</button>
|
||||
${edit
|
||||
? html`<button class="btn on" @click=${() => this._saveRoomEdit()} ?disabled=${!this._nameSel.trim()}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||
</button>`
|
||||
: html`<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
|
||||
title=${this._t('title.no_area_room')}>
|
||||
${this._t('btn.no_area')}
|
||||
</button>
|
||||
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
|
||||
title=${!this._areaSel ? this._t('title.choose_area') : ''}>
|
||||
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
|
||||
</button>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
Reference in New Issue
Block a user