mirror of
https://github.com/home-assistant/frontend.git
synced 2026-01-24 08:29:09 +00:00
Compare commits
2 Commits
supervisor
...
unassigned
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55c8fbc2b9 | ||
|
|
3a5175781d |
@@ -20,6 +20,7 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-button";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
@@ -95,6 +96,9 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
@property({ type: Boolean, attribute: "button-style" })
|
||||
public buttonStyle = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
public async open() {
|
||||
@@ -390,10 +394,26 @@ export class HaAreaPicker extends LitElement {
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
${this.buttonStyle
|
||||
? html`<ha-button
|
||||
slot="field"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openPicker}
|
||||
appearance="plain"
|
||||
size="small"
|
||||
>
|
||||
${placeholder}
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _openPicker(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._picker?.open();
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
15
src/data/device/unassigned_devices.ts
Normal file
15
src/data/device/unassigned_devices.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { DeviceRegistryEntry } from "./device_registry";
|
||||
|
||||
export const filterUnassignedDevices = (
|
||||
devices: Record<string, DeviceRegistryEntry>
|
||||
): DeviceRegistryEntry[] =>
|
||||
Object.values(devices).filter(
|
||||
(device) =>
|
||||
device.area_id === null &&
|
||||
device.disabled_by === null &&
|
||||
device.entry_type !== "service"
|
||||
);
|
||||
|
||||
export const countUnassignedDevices = (
|
||||
devices: Record<string, DeviceRegistryEntry>
|
||||
): number => filterUnassignedDevices(devices).length;
|
||||
@@ -795,7 +795,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this._searchParms.has("historyBack")
|
||||
? undefined
|
||||
: "/config"}
|
||||
: "/config/devices"}
|
||||
.tabs=${configSections.devices}
|
||||
.route=${this.route}
|
||||
.searchLabel=${this.hass.localize(
|
||||
|
||||
367
src/panels/config/devices/ha-config-devices-unassigned.ts
Normal file
367
src/panels/config/devices/ha-config-devices-unassigned.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
|
||||
import "../../../components/ha-area-picker";
|
||||
import type { ConfigEntry } from "../../../data/config_entries";
|
||||
import { sortConfigEntries } from "../../../data/config_entries";
|
||||
import { fullEntitiesContext } from "../../../data/context";
|
||||
import type { DeviceEntityLookup } from "../../../data/device/device_registry";
|
||||
import { updateDeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import { filterUnassignedDevices } from "../../../data/device/unassigned_devices";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
|
||||
const TABS: PageNavigation[] = [
|
||||
{
|
||||
path: "/config/devices/unassigned",
|
||||
translationKey: "ui.panel.config.devices.unassigned.caption",
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("ha-config-devices-unassigned")
|
||||
export class HaConfigDevicesUnassigned extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ attribute: false }) public entries!: ConfigEntry[];
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
entities!: EntityRegistryEntry[];
|
||||
|
||||
@property({ attribute: false }) public manifests!: IntegrationManifest[];
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "devices-unassigned-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter = "";
|
||||
|
||||
@storage({
|
||||
key: "devices-unassigned-table-sort",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeSorting?: SortingChangedEvent;
|
||||
|
||||
@storage({
|
||||
key: "devices-unassigned-table-grouping",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeGrouping?: string;
|
||||
|
||||
@storage({
|
||||
key: "devices-unassigned-table-collapsed",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeCollapsed?: string;
|
||||
|
||||
@storage({
|
||||
key: "devices-unassigned-table-column-order",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeColumnOrder?: string[];
|
||||
|
||||
@storage({
|
||||
key: "devices-unassigned-table-hidden-columns",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeHiddenColumns?: string[];
|
||||
|
||||
private _unassignedDevices = memoizeOne(
|
||||
(
|
||||
devices: HomeAssistant["devices"],
|
||||
entries: ConfigEntry[],
|
||||
entities: EntityRegistryEntry[],
|
||||
localize: LocalizeFunc
|
||||
) => {
|
||||
const deviceEntityLookup: DeviceEntityLookup<EntityRegistryEntry> = {};
|
||||
for (const entity of entities) {
|
||||
if (!entity.device_id) {
|
||||
continue;
|
||||
}
|
||||
if (!(entity.device_id in deviceEntityLookup)) {
|
||||
deviceEntityLookup[entity.device_id] = [];
|
||||
}
|
||||
deviceEntityLookup[entity.device_id].push(entity);
|
||||
}
|
||||
|
||||
const entryLookup: Record<string, ConfigEntry> = {};
|
||||
for (const entry of entries) {
|
||||
entryLookup[entry.entry_id] = entry;
|
||||
}
|
||||
|
||||
const unassignedDevices = filterUnassignedDevices(devices);
|
||||
|
||||
return unassignedDevices.map((device) => {
|
||||
const deviceEntries = sortConfigEntries(
|
||||
device.config_entries
|
||||
.filter((entId) => entId in entryLookup)
|
||||
.map((entId) => entryLookup[entId]),
|
||||
device.primary_config_entry
|
||||
);
|
||||
|
||||
return {
|
||||
...device,
|
||||
name: computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass,
|
||||
deviceEntityLookup[device.id]
|
||||
),
|
||||
model:
|
||||
device.model ||
|
||||
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
|
||||
manufacturer:
|
||||
device.manufacturer ||
|
||||
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
|
||||
integration: deviceEntries.length
|
||||
? deviceEntries
|
||||
.map(
|
||||
(entry) =>
|
||||
localize(`component.${entry.domain}.title`) || entry.domain
|
||||
)
|
||||
.join(", ")
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.devices.data_table.no_integration"
|
||||
),
|
||||
domains: deviceEntries.map((entry) => entry.domain),
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _columns = memoizeOne((localize: LocalizeFunc, narrow: boolean) => {
|
||||
type DeviceItem = ReturnType<typeof this._unassignedDevices>[number];
|
||||
|
||||
const columns: DataTableColumnContainer<DeviceItem> = {
|
||||
icon: {
|
||||
title: "",
|
||||
label: localize("ui.panel.config.devices.data_table.icon"),
|
||||
type: "icon",
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (device) =>
|
||||
device.domains.length
|
||||
? html`<img
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl({
|
||||
domain: device.domains[0],
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
})}
|
||||
/>`
|
||||
: "",
|
||||
},
|
||||
name: {
|
||||
title: localize("ui.panel.config.devices.data_table.device"),
|
||||
main: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
direction: "asc",
|
||||
flex: 2,
|
||||
minWidth: "150px",
|
||||
},
|
||||
integration: {
|
||||
title: localize("ui.panel.config.devices.data_table.integration"),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
minWidth: "120px",
|
||||
},
|
||||
manufacturer: {
|
||||
title: localize("ui.panel.config.devices.data_table.manufacturer"),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
minWidth: "120px",
|
||||
defaultHidden: narrow,
|
||||
},
|
||||
model: {
|
||||
title: localize("ui.panel.config.devices.data_table.model"),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
minWidth: "120px",
|
||||
defaultHidden: narrow,
|
||||
},
|
||||
assign: {
|
||||
title: "",
|
||||
label: localize("ui.panel.config.devices.unassigned.assign"),
|
||||
type: "overflow-menu",
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
minWidth: "150px",
|
||||
template: (device) => html`
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
data-device-id=${device.id}
|
||||
.placeholder=${localize(
|
||||
"ui.panel.config.devices.unassigned.assign"
|
||||
)}
|
||||
no-add
|
||||
button-style
|
||||
@value-changed=${this._assignArea}
|
||||
></ha-area-picker>
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
return columns;
|
||||
});
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.hass || !this.entries || !this.entities) {
|
||||
return nothing as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
const devicesOutput = this._unassignedDevices(
|
||||
this.hass.devices,
|
||||
this.entries,
|
||||
this.entities,
|
||||
this.hass.localize
|
||||
);
|
||||
|
||||
return html`
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this._searchParms.has("historyBack")
|
||||
? undefined
|
||||
: "/config/devices/dashboard"}
|
||||
.tabs=${TABS}
|
||||
.route=${this.route}
|
||||
.searchLabel=${this.hass.localize(
|
||||
"ui.panel.config.devices.unassigned.search",
|
||||
{ number: devicesOutput.length }
|
||||
)}
|
||||
.columns=${this._columns(this.hass.localize, this.narrow)}
|
||||
.data=${devicesOutput}
|
||||
.filter=${this._filter}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._activeSorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
.hiddenColumns=${this._activeHiddenColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
@row-click=${this._handleRowClicked}
|
||||
clickable
|
||||
class=${this.narrow ? "narrow" : ""}
|
||||
.noDataText=${this.hass.localize(
|
||||
"ui.panel.config.devices.unassigned.no_devices"
|
||||
)}
|
||||
>
|
||||
</hass-tabs-subpage-data-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
const deviceId = ev.detail.id;
|
||||
navigate(`/config/devices/device/${deviceId}`);
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
}
|
||||
|
||||
private _assignArea = async (ev: CustomEvent) => {
|
||||
const areaPicker = ev.currentTarget as any;
|
||||
const deviceId = areaPicker.dataset.deviceId;
|
||||
const areaId = ev.detail.value;
|
||||
|
||||
if (!areaId || !deviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the picker
|
||||
areaPicker.value = undefined;
|
||||
|
||||
try {
|
||||
await updateDeviceRegistryEntry(this.hass, deviceId, {
|
||||
area_id: areaId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: err.message || "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(ev: CustomEvent) {
|
||||
this._activeGrouping = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(ev: CustomEvent) {
|
||||
this._activeCollapsed = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleColumnsChanged(ev: CustomEvent) {
|
||||
this._activeColumnOrder = ev.detail.columnOrder;
|
||||
this._activeHiddenColumns = ev.detail.hiddenColumns;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
hass-tabs-subpage-data-table {
|
||||
--data-table-row-height: 60px;
|
||||
}
|
||||
hass-tabs-subpage-data-table.narrow {
|
||||
--data-table-row-height: 72px;
|
||||
}
|
||||
`,
|
||||
haStyle,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-devices-unassigned": HaConfigDevicesUnassigned;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { HassRouterPage } from "../../../layouts/hass-router-page";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-config-device-page";
|
||||
import "./ha-config-devices-dashboard";
|
||||
import "./ha-config-devices-unassigned";
|
||||
|
||||
@customElement("ha-config-devices")
|
||||
class HaConfigDevices extends HassRouterPage {
|
||||
@@ -26,6 +27,10 @@ class HaConfigDevices extends HassRouterPage {
|
||||
tag: "ha-config-devices-dashboard",
|
||||
cache: true,
|
||||
},
|
||||
unassigned: {
|
||||
tag: "ha-config-devices-unassigned",
|
||||
cache: true,
|
||||
},
|
||||
device: {
|
||||
tag: "ha-config-device-page",
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import "../../../components/ha-icon";
|
||||
import "../../../components/ha-ripple";
|
||||
import "../../../components/tile/ha-tile-icon";
|
||||
import "../../../components/tile/ha-tile-info";
|
||||
import { countUnassignedDevices } from "../../../data/device/unassigned_devices";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import "../../../state-display/state-display";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -35,6 +36,7 @@ const COLORS: Record<HomeSummary, string> = {
|
||||
climate: "deep-orange",
|
||||
security: "blue-grey",
|
||||
media_players: "blue",
|
||||
unassigned_devices: "grey",
|
||||
};
|
||||
|
||||
@customElement("hui-home-summary-card")
|
||||
@@ -214,6 +216,13 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
|
||||
})
|
||||
: this.hass.localize("ui.card.home-summary.no_media_playing");
|
||||
}
|
||||
case "unassigned_devices": {
|
||||
const count = countUnassignedDevices(this.hass.devices);
|
||||
return this.hass.localize(
|
||||
"ui.card.home-summary.count_unassigned_devices",
|
||||
{ count }
|
||||
);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const HOME_SUMMARIES = [
|
||||
"climate",
|
||||
"security",
|
||||
"media_players",
|
||||
"unassigned_devices",
|
||||
] as const;
|
||||
|
||||
export type HomeSummary = (typeof HOME_SUMMARIES)[number];
|
||||
@@ -18,6 +19,7 @@ export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
|
||||
climate: "mdi:home-thermometer",
|
||||
security: "mdi:security",
|
||||
media_players: "mdi:multimedia",
|
||||
unassigned_devices: "mdi:devices",
|
||||
};
|
||||
|
||||
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
|
||||
@@ -25,6 +27,7 @@ export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
|
||||
climate: climateEntityFilters,
|
||||
security: securityEntityFilters,
|
||||
media_players: [{ domain: "media_player", entity_category: "none" }],
|
||||
unassigned_devices: [], // Device-based, not entity-based
|
||||
};
|
||||
|
||||
export const getSummaryLabel = (
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
WeatherForecastCardConfig,
|
||||
} from "../../cards/types";
|
||||
import type { CommonControlSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
|
||||
import { countUnassignedDevices } from "../../../../data/device/unassigned_devices";
|
||||
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
|
||||
import type { Condition } from "../../common/validate-condition";
|
||||
|
||||
@@ -170,6 +171,10 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
const hasClimate = findEntities(allEntities, climateFilters).length > 0;
|
||||
const hasSecurity = findEntities(allEntities, securityFilters).length > 0;
|
||||
|
||||
const unassignedDevicesCount = countUnassignedDevices(hass.devices);
|
||||
const hasUnassignedDevices =
|
||||
hass.user?.is_admin && unassignedDevicesCount > 0;
|
||||
|
||||
const summaryCards: LovelaceCardConfig[] = [
|
||||
hasLights &&
|
||||
({
|
||||
@@ -219,6 +224,18 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
columns: 12,
|
||||
},
|
||||
} satisfies HomeSummaryCard),
|
||||
hasUnassignedDevices &&
|
||||
({
|
||||
type: "home-summary",
|
||||
summary: "unassigned_devices",
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/config/devices/unassigned?historyBack=1",
|
||||
},
|
||||
grid_options: {
|
||||
columns: 12,
|
||||
},
|
||||
} satisfies HomeSummaryCard),
|
||||
].filter(Boolean) as LovelaceCardConfig[];
|
||||
|
||||
const forYouSection: LovelaceSectionConfig = {
|
||||
|
||||
@@ -212,7 +212,8 @@
|
||||
"count_alarms_disarmed": "{count} {count, plural,\n one {disarmed}\n other {disarmed}\n}",
|
||||
"all_secure": "All secure",
|
||||
"no_media_playing": "No media playing",
|
||||
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}"
|
||||
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}",
|
||||
"count_unassigned_devices": "{count} {count, plural,\n one {device}\n other {devices}\n}"
|
||||
},
|
||||
"media_player": {
|
||||
"source": "Source",
|
||||
@@ -5494,6 +5495,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"unassigned": {
|
||||
"caption": "Unassigned devices",
|
||||
"search": "Search {number} unassigned {number, plural,\n one {device}\n other {devices}\n}",
|
||||
"no_devices": "All devices are assigned to areas",
|
||||
"assign": "Assign to area"
|
||||
},
|
||||
"esphome": {
|
||||
"show_encryption_key": "Show encryption key",
|
||||
"encryption_key_title": "ESPHome Encryption Key",
|
||||
@@ -7148,7 +7155,8 @@
|
||||
},
|
||||
"home": {
|
||||
"summary_list": {
|
||||
"media_players": "Media players"
|
||||
"media_players": "Media players",
|
||||
"unassigned_devices": "Unassigned devices"
|
||||
},
|
||||
"welcome_user": "Welcome {user}",
|
||||
"summaries": "Summaries",
|
||||
|
||||
Reference in New Issue
Block a user