mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-13 12:26:35 +00:00
Add picture uploader to area (#10544)
This commit is contained in:
parent
6623e5f017
commit
4cb45d6313
@ -340,7 +340,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
|
||||
item-value-path="area_id"
|
||||
item-id-path="area_id"
|
||||
item-label-path="name"
|
||||
.value=${this._value}
|
||||
.value=${this.value}
|
||||
.disabled=${this.disabled}
|
||||
${comboBoxRenderer(rowRenderer)}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@ -431,12 +431,24 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
|
||||
name,
|
||||
});
|
||||
this._areas = [...this._areas!, area];
|
||||
(this.comboBox as any).items = this._getAreas(
|
||||
this._areas!,
|
||||
this._devices!,
|
||||
this._entities!,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.noAdd
|
||||
);
|
||||
this._setValue(area.area_id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
title: this.hass.localize(
|
||||
"ui.components.area-picker.add_dialog.failed_create_area"
|
||||
),
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -7,10 +7,12 @@ import { HomeAssistant } from "../types";
|
||||
export interface AreaRegistryEntry {
|
||||
area_id: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export interface AreaRegistryEntryMutableParams {
|
||||
name: string;
|
||||
picture?: string | null;
|
||||
}
|
||||
|
||||
export const createAreaRegistryEntry = (
|
||||
|
@ -66,7 +66,7 @@ export const computeEntityRegistryName = (
|
||||
return entry.name;
|
||||
}
|
||||
const state = hass.states[entry.entity_id];
|
||||
return state ? computeStateName(state) : null;
|
||||
return state ? computeStateName(state) : entry.entity_id;
|
||||
};
|
||||
|
||||
export const getExtendedEntityRegistryEntry = (
|
||||
|
@ -4,7 +4,7 @@ export interface CropOptions {
|
||||
round: boolean;
|
||||
type?: "image/jpeg" | "image/png";
|
||||
quality?: number;
|
||||
aspectRatio: number;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
export interface HaImageCropperDialogParams {
|
||||
|
@ -3,19 +3,31 @@ import "@polymer/paper-input/paper-input";
|
||||
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { createCloseHeading } from "../../../components/ha-dialog";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-picture-upload";
|
||||
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
|
||||
import { AreaRegistryEntryMutableParams } from "../../../data/area_registry";
|
||||
import { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
|
||||
import { PolymerChangedEvent } from "../../../polymer-types";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import { HomeAssistant } from "../../../types";
|
||||
import { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
|
||||
|
||||
const cropOptions: CropOptions = {
|
||||
round: false,
|
||||
type: "image/jpeg",
|
||||
quality: 0.75,
|
||||
aspectRatio: 1.78,
|
||||
};
|
||||
|
||||
class DialogAreaDetail extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _name!: string;
|
||||
|
||||
@state() private _picture!: string | null;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _params?: AreaRegistryDetailDialogParams;
|
||||
@ -28,6 +40,7 @@ class DialogAreaDetail extends LitElement {
|
||||
this._params = params;
|
||||
this._error = undefined;
|
||||
this._name = this._params.entry ? this._params.entry.name : "";
|
||||
this._picture = this._params.entry?.picture || null;
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
@ -55,7 +68,9 @@ class DialogAreaDetail extends LitElement {
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
|
||||
${this._error
|
||||
? html` <ha-alert alert-type="error">${this._error}</ha-alert> `
|
||||
: ""}
|
||||
<div class="form">
|
||||
${entry
|
||||
? html`
|
||||
@ -78,6 +93,13 @@ class DialogAreaDetail extends LitElement {
|
||||
)}
|
||||
.invalid=${nameInvalid}
|
||||
></paper-input>
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${this._picture}
|
||||
crop
|
||||
.cropOptions=${cropOptions}
|
||||
@change=${this._pictureChanged}
|
||||
></ha-picture-upload>
|
||||
</div>
|
||||
</div>
|
||||
${entry
|
||||
@ -120,18 +142,24 @@ class DialogAreaDetail extends LitElement {
|
||||
this._name = ev.detail.value;
|
||||
}
|
||||
|
||||
private _pictureChanged(ev: PolymerChangedEvent<string | null>) {
|
||||
this._error = undefined;
|
||||
this._picture = (ev.target as HaPictureUpload).value;
|
||||
}
|
||||
|
||||
private async _updateEntry() {
|
||||
this._submitting = true;
|
||||
try {
|
||||
const values: AreaRegistryEntryMutableParams = {
|
||||
name: this._name.trim(),
|
||||
picture: this._picture,
|
||||
};
|
||||
if (this._params!.entry) {
|
||||
await this._params!.updateEntry!(values);
|
||||
} else {
|
||||
await this._params!.createEntry!(values);
|
||||
}
|
||||
this._params = undefined;
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
this._error =
|
||||
err.message ||
|
||||
@ -145,13 +173,11 @@ class DialogAreaDetail extends LitElement {
|
||||
this._submitting = true;
|
||||
try {
|
||||
if (await this._params!.removeEntry!()) {
|
||||
this._params = undefined;
|
||||
this.closeDialog();
|
||||
}
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
|
||||
navigate("/config/areas/dashboard");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@ -161,9 +187,6 @@ class DialogAreaDetail extends LitElement {
|
||||
.form {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -1,13 +1,17 @@
|
||||
import "@material/mwc-button";
|
||||
import { mdiCog } from "@mdi/js";
|
||||
import "@polymer/paper-item/paper-item";
|
||||
import "@polymer/paper-item/paper-item-body";
|
||||
import { mdiImagePlus, mdiPencil } from "@mdi/js";
|
||||
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import { afterNextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import {
|
||||
AreaRegistryEntry,
|
||||
deleteAreaRegistryEntry,
|
||||
@ -134,25 +138,59 @@ class HaConfigAreaPage extends LitElement {
|
||||
.tabs=${configSections.integrations}
|
||||
.route=${this.route}
|
||||
>
|
||||
${this.narrow ? html` <span slot="header"> ${area.name} </span> ` : ""}
|
||||
|
||||
${this.narrow
|
||||
? html`<span slot="header"> ${area.name} </span>
|
||||
<ha-icon-button
|
||||
slot="toolbar-icon"
|
||||
.path=${mdiCog}
|
||||
.path=${mdiPencil}
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
.label=${this.hass.localize("ui.panel.config.areas.edit_settings")}
|
||||
></ha-icon-button>
|
||||
slot="toolbar-icon"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.edit_settings"
|
||||
)}
|
||||
></ha-icon-button>`
|
||||
: ""}
|
||||
|
||||
<div class="container">
|
||||
${!this.narrow
|
||||
? html`
|
||||
<div class="fullwidth">
|
||||
<h1>${area.name}</h1>
|
||||
<h1>
|
||||
${area.name}
|
||||
<ha-icon-button
|
||||
.path=${mdiPencil}
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.edit_settings"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</h1>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<div class="column">
|
||||
${area.picture
|
||||
? html`<div class="img-container">
|
||||
<img src=${area.picture} /><ha-icon-button
|
||||
.path=${mdiPencil}
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.edit_settings"
|
||||
)}
|
||||
class="img-edit-btn"
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
: html`<mwc-button
|
||||
.entry=${area}
|
||||
@click=${this._showSettings}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.areas.add_picture"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiImagePlus} slot="icon"></ha-svg-icon>
|
||||
</mwc-button>`}
|
||||
<ha-card
|
||||
.header=${this.hass.localize("ui.panel.config.devices.caption")}
|
||||
>${devices.length
|
||||
@ -181,7 +219,8 @@ class HaConfigAreaPage extends LitElement {
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.areas.editor.linked_entities_caption"
|
||||
)}
|
||||
>${entities.length
|
||||
>
|
||||
${entities.length
|
||||
? entities.map(
|
||||
(entity) =>
|
||||
html`
|
||||
@ -390,6 +429,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
|
||||
try {
|
||||
await deleteAreaRegistryEntry(this.hass!, entry!.area_id);
|
||||
afterNextRender(() => history.back());
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return false;
|
||||
@ -403,7 +443,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
margin: 0;
|
||||
font-family: var(--paper-font-headline_-_font-family);
|
||||
-webkit-font-smoothing: var(
|
||||
--paper-font-headline_-_-webkit-font-smoothing
|
||||
@ -413,6 +453,13 @@ class HaConfigAreaPage extends LitElement {
|
||||
letter-spacing: var(--paper-font-headline_-_letter-spacing);
|
||||
line-height: var(--paper-font-headline_-_line-height);
|
||||
opacity: var(--dark-primary-opacity);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
@ -458,6 +505,34 @@ class HaConfigAreaPage extends LitElement {
|
||||
paper-item.no-link {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
ha-card > a:first-child {
|
||||
display: block;
|
||||
}
|
||||
ha-card > *:first-child {
|
||||
margin-top: -16px;
|
||||
}
|
||||
.img-container {
|
||||
position: relative;
|
||||
}
|
||||
.img-edit-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: none;
|
||||
}
|
||||
.img-container:hover .img-edit-btn {
|
||||
display: block;
|
||||
}
|
||||
.img-edit-btn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--card-background-color);
|
||||
opacity: 0.5;
|
||||
border-radius: 50%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -1,15 +1,8 @@
|
||||
import { mdiHelpCircle, mdiPlus } from "@mdi/js";
|
||||
import "@polymer/paper-item/paper-item";
|
||||
import "@polymer/paper-item/paper-item-body";
|
||||
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/ha-fab";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-svg-icon";
|
||||
@ -21,7 +14,7 @@ import type { DeviceRegistryEntry } from "../../../data/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity_registry";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import "../../../layouts/hass-tabs-subpage";
|
||||
import { HomeAssistant, Route } from "../../../types";
|
||||
import "../ha-config-section";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
@ -53,15 +46,22 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
entities: EntityRegistryEntry[]
|
||||
) =>
|
||||
areas.map((area) => {
|
||||
let noDevicesInArea = 0;
|
||||
let noServicesInArea = 0;
|
||||
let noEntitiesInArea = 0;
|
||||
|
||||
const devicesInArea = new Set();
|
||||
|
||||
for (const device of devices) {
|
||||
if (device.area_id === area.area_id) {
|
||||
devicesInArea.add(device.id);
|
||||
if (device.entry_type === "service") {
|
||||
noServicesInArea++;
|
||||
} else {
|
||||
noDevicesInArea++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let entitiesInArea = 0;
|
||||
|
||||
for (const entity of entities) {
|
||||
if (
|
||||
@ -69,81 +69,28 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
? entity.area_id === area.area_id
|
||||
: devicesInArea.has(entity.device_id)
|
||||
) {
|
||||
entitiesInArea++;
|
||||
noEntitiesInArea++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...area,
|
||||
devices: devicesInArea.size,
|
||||
entities: entitiesInArea,
|
||||
devices: noDevicesInArea,
|
||||
services: noServicesInArea,
|
||||
entities: noEntitiesInArea,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
private _columns = memoizeOne(
|
||||
(narrow: boolean): DataTableColumnContainer =>
|
||||
narrow
|
||||
? {
|
||||
name: {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.areas.data_table.area"
|
||||
),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
grows: true,
|
||||
direction: "asc",
|
||||
},
|
||||
}
|
||||
: {
|
||||
name: {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.areas.data_table.area"
|
||||
),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
grows: true,
|
||||
direction: "asc",
|
||||
},
|
||||
devices: {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.areas.data_table.devices"
|
||||
),
|
||||
sortable: true,
|
||||
type: "numeric",
|
||||
width: "20%",
|
||||
direction: "asc",
|
||||
},
|
||||
entities: {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.areas.data_table.entities"
|
||||
),
|
||||
sortable: true,
|
||||
type: "numeric",
|
||||
width: "20%",
|
||||
direction: "asc",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-tabs-subpage-data-table
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.isWide=${this.isWide}
|
||||
back-path="/config"
|
||||
.tabs=${configSections.integrations}
|
||||
.route=${this.route}
|
||||
.columns=${this._columns(this.narrow)}
|
||||
.data=${this._areas(this.areas, this.devices, this.entities)}
|
||||
@row-click=${this._handleRowClicked}
|
||||
.noDataText=${this.hass.localize(
|
||||
"ui.panel.config.areas.picker.no_areas"
|
||||
)}
|
||||
id="area_id"
|
||||
hasFab
|
||||
clickable
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="toolbar-icon"
|
||||
@ -151,6 +98,58 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
.path=${mdiHelpCircle}
|
||||
@click=${this._showHelp}
|
||||
></ha-icon-button>
|
||||
<div class="container">
|
||||
${this._areas(this.areas, this.devices, this.entities).map(
|
||||
(area) =>
|
||||
html`<a href=${`/config/areas/area/${area.area_id}`}
|
||||
><ha-card outlined>
|
||||
<div
|
||||
style=${styleMap({
|
||||
backgroundImage: area.picture
|
||||
? `url(${area.picture})`
|
||||
: undefined,
|
||||
})}
|
||||
class="picture ${!area.picture ? "placeholder" : ""}"
|
||||
></div>
|
||||
<h1 class="card-header">${area.name}</h1>
|
||||
<div class="card-content">
|
||||
<div>
|
||||
${area.devices
|
||||
? html`
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.devices",
|
||||
"count",
|
||||
area.devices
|
||||
)}${area.services ? "," : ""}
|
||||
`
|
||||
: ""}
|
||||
${area.services
|
||||
? html`
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.services",
|
||||
"count",
|
||||
area.services
|
||||
)}
|
||||
`
|
||||
: ""}
|
||||
${(area.devices || area.services) && area.entities
|
||||
? this.hass.localize("ui.common.and")
|
||||
: ""}
|
||||
${area.entities
|
||||
? html`
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.entities",
|
||||
"count",
|
||||
area.entities
|
||||
)}
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card></a
|
||||
>`
|
||||
)}
|
||||
</div>
|
||||
<ha-fab
|
||||
slot="fab"
|
||||
.label=${this.hass.localize(
|
||||
@ -161,7 +160,7 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-fab>
|
||||
</hass-tabs-subpage-data-table>
|
||||
</hass-tabs-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -191,11 +190,6 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
const areaId = ev.detail.id;
|
||||
navigate(`/config/areas/area/${areaId}`);
|
||||
}
|
||||
|
||||
private _openDialog(entry?: AreaRegistryEntry) {
|
||||
showAreaRegistryDetailDialog(this, {
|
||||
entry,
|
||||
@ -210,6 +204,51 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
--app-header-background-color: var(--sidebar-background-color);
|
||||
--app-header-text-color: var(--sidebar-text-color);
|
||||
}
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-gap: 16px 16px;
|
||||
padding: 8px 16px 16px;
|
||||
margin: 0 auto 64px auto;
|
||||
max-width: 1000px;
|
||||
}
|
||||
.container > * {
|
||||
max-width: 500px;
|
||||
}
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
h1 {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.picture {
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
.picture.placeholder::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--sidebar-selected-icon-color);
|
||||
opacity: 0.12;
|
||||
}
|
||||
.card-content {
|
||||
min-height: 16px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-areas-dashboard": HaConfigAreasDashboard;
|
||||
}
|
||||
}
|
||||
|
@ -376,9 +376,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
|
||||
result.push({
|
||||
...entry,
|
||||
entity,
|
||||
name:
|
||||
computeEntityRegistryName(this.hass!, entry) ||
|
||||
this.hass.localize("state.default.unavailable"),
|
||||
name: computeEntityRegistryName(this.hass!, entry),
|
||||
unavailable,
|
||||
restored,
|
||||
area: area ? area.name : undefined,
|
||||
|
@ -930,6 +930,7 @@
|
||||
"caption": "Areas",
|
||||
"description": "Group devices and entities into areas",
|
||||
"edit_settings": "Area settings",
|
||||
"add_picture": "Add a picture",
|
||||
"data_table": {
|
||||
"area": "Area",
|
||||
"devices": "Devices",
|
||||
|
Loading…
x
Reference in New Issue
Block a user