mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-06 09:29:40 +00:00
Compare commits
20 Commits
20251104.0
...
feature/ti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce17e3dea | ||
|
|
ef9029829a | ||
|
|
8bc53c61f2 | ||
|
|
711dcdbff0 | ||
|
|
6834e458d7 | ||
|
|
d597239925 | ||
|
|
004b3ce025 | ||
|
|
43e7b55e99 | ||
|
|
ea5fe14a64 | ||
|
|
807fbf8bb6 | ||
|
|
44cd425ce8 | ||
|
|
af01f66329 | ||
|
|
d5892b372c | ||
|
|
8656df6129 | ||
|
|
8c543ee67c | ||
|
|
4789d8c793 | ||
|
|
f08bbe7c1e | ||
|
|
9f1ee988bc | ||
|
|
eba0fa35d3 | ||
|
|
5b8c5375b4 |
121
src/components/ha-numeric-arrow-input.ts
Normal file
121
src/components/ha-numeric-arrow-input.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { mdiMinus, mdiPlus } from "@mdi/js";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HaIconButton } from "./ha-icon-button";
|
||||
import "./ha-textfield";
|
||||
import "./ha-icon-button";
|
||||
import { clampValue } from "../data/number";
|
||||
|
||||
@customElement("ha-numeric-arrow-input")
|
||||
export class HaNumericArrowInput extends LitElement {
|
||||
@property({ attribute: false }) public disabled = false;
|
||||
|
||||
@property({ attribute: false }) public required = false;
|
||||
|
||||
@property({ attribute: false }) public min?: number;
|
||||
|
||||
@property({ attribute: false }) public max?: number;
|
||||
|
||||
@property({ attribute: false }) public step?: number;
|
||||
|
||||
@property({ attribute: false }) public padStart?: number;
|
||||
|
||||
@property({ attribute: false }) public labelUp = "Increase";
|
||||
|
||||
@property({ attribute: false }) public labelDown = "Decrease";
|
||||
|
||||
@property({ attribute: false }) public value = 0;
|
||||
|
||||
@query("ha-icon-button[data-direction='up']")
|
||||
private _upButton!: HaIconButton;
|
||||
|
||||
@query("ha-icon-button[data-direction='down']")
|
||||
private _downButton!: HaIconButton;
|
||||
|
||||
private _paddedValue = memoizeOne((value: number, padStart?: number) =>
|
||||
value.toString().padStart(padStart ?? 0, "0")
|
||||
);
|
||||
|
||||
render() {
|
||||
return html`<div
|
||||
class="numeric-arrow-input-container"
|
||||
@keydown=${this._keyDown}
|
||||
>
|
||||
<ha-icon-button
|
||||
data-direction="up"
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.labelUp}
|
||||
.path=${mdiPlus}
|
||||
@click=${this._up}
|
||||
></ha-icon-button>
|
||||
<span class="numeric-arrow-input-value"
|
||||
>${this._paddedValue(this.value, this.padStart)}</span
|
||||
>
|
||||
<ha-icon-button
|
||||
data-direction="down"
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.labelDown}
|
||||
.path=${mdiMinus}
|
||||
@click=${this._down}
|
||||
></ha-icon-button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _keyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "ArrowUp") {
|
||||
this._upButton.focus();
|
||||
this._up();
|
||||
}
|
||||
if (ev.key === "ArrowDown") {
|
||||
this._downButton.focus();
|
||||
this._down();
|
||||
}
|
||||
}
|
||||
|
||||
private _up() {
|
||||
const newValue = this.value + (this.step ?? 1);
|
||||
fireEvent(
|
||||
this,
|
||||
"value-changed",
|
||||
clampValue({ value: newValue, min: this.min, max: this.max })
|
||||
);
|
||||
}
|
||||
|
||||
private _down() {
|
||||
const newValue = this.value - (this.step ?? 1);
|
||||
fireEvent(
|
||||
this,
|
||||
"value-changed",
|
||||
clampValue({ value: newValue, min: this.min, max: this.max })
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.numeric-arrow-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.numeric-arrow-input-container ha-icon-button {
|
||||
--mdc-icon-button-size: 24px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.numeric-arrow-input-value {
|
||||
color: var(--primary-text-color);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-numeric-arrow-input": HaNumericArrowInput;
|
||||
}
|
||||
}
|
||||
281
src/components/ha-time-picker.ts
Normal file
281
src/components/ha-time-picker.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { clampValue } from "../data/number";
|
||||
import { useAmPm } from "../common/datetime/use_am_pm";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { FrontendLocaleData } from "../data/translation";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { ClampedValue } from "../data/number";
|
||||
import "./ha-base-time-input";
|
||||
import "./ha-button";
|
||||
import "./ha-numeric-arrow-input";
|
||||
|
||||
@customElement("ha-time-picker")
|
||||
export class HaTimePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public locale!: FrontendLocaleData;
|
||||
|
||||
@property({ attribute: false }) public value?: string;
|
||||
|
||||
@property({ attribute: false }) public disabled = false;
|
||||
|
||||
@property({ attribute: false }) public required = false;
|
||||
|
||||
@property({ attribute: false }) public enableSeconds = false;
|
||||
|
||||
@state() private _hours = 0;
|
||||
|
||||
@state() private _minutes = 0;
|
||||
|
||||
@state() private _seconds = 0;
|
||||
|
||||
@state() private _useAmPm = false;
|
||||
|
||||
@state() private _isPm = false;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this._useAmPm = useAmPm(this.locale);
|
||||
|
||||
let hours = NaN;
|
||||
let minutes = NaN;
|
||||
let seconds = NaN;
|
||||
let isPm = false;
|
||||
|
||||
if (this.value) {
|
||||
const parts = this.value?.split(":") || [];
|
||||
minutes = parts[1] ? Number(parts[1]) : 0;
|
||||
seconds = parts[2] ? Number(parts[2]) : 0;
|
||||
const hour24 = parts[0] ? Number(parts[0]) : 0;
|
||||
|
||||
if (this._useAmPm) {
|
||||
if (hour24 === 0) {
|
||||
hours = 12;
|
||||
isPm = false;
|
||||
} else if (hour24 < 12) {
|
||||
hours = hour24;
|
||||
isPm = false;
|
||||
} else if (hour24 === 12) {
|
||||
hours = 12;
|
||||
isPm = true;
|
||||
} else {
|
||||
hours = hour24 - 12;
|
||||
isPm = true;
|
||||
}
|
||||
} else {
|
||||
hours = hour24;
|
||||
}
|
||||
}
|
||||
|
||||
this._hours = hours;
|
||||
this._minutes = minutes;
|
||||
this._seconds = seconds;
|
||||
this._isPm = isPm;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`<div class="time-picker-container">
|
||||
<ha-numeric-arrow-input
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.min=${this._useAmPm ? 1 : 0}
|
||||
.max=${this._useAmPm ? 12 : 23}
|
||||
.step=${1}
|
||||
.padStart=${2}
|
||||
.value=${this._hours}
|
||||
@value-changed=${this._hoursChanged}
|
||||
.labelUp=${
|
||||
// TODO: Localize
|
||||
"Increase hours"
|
||||
}
|
||||
.labelDown=${
|
||||
// TODO: Localize
|
||||
"Decrease hours"
|
||||
}
|
||||
></ha-numeric-arrow-input>
|
||||
<span class="time-picker-separator">:</span>
|
||||
<ha-numeric-arrow-input
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.min=${0}
|
||||
.max=${59}
|
||||
.step=${1}
|
||||
.padStart=${2}
|
||||
.labelUp=${
|
||||
// TODO: Localize
|
||||
"Increase minutes"
|
||||
}
|
||||
.labelDown=${
|
||||
// TODO: Localize
|
||||
"Decrease minutes"
|
||||
}
|
||||
.value=${this._minutes}
|
||||
@value-changed=${this._minutesChanged}
|
||||
></ha-numeric-arrow-input>
|
||||
${this.enableSeconds
|
||||
? html`
|
||||
<span class="time-picker-separator">:</span>
|
||||
<ha-numeric-arrow-input
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.min=${0}
|
||||
.max=${59}
|
||||
.step=${1}
|
||||
.padStart=${2}
|
||||
.labelUp=${
|
||||
// TODO: Localize
|
||||
"Increase seconds"
|
||||
}
|
||||
.labelDown=${
|
||||
// TODO: Localize
|
||||
"Decrease seconds"
|
||||
}
|
||||
.value=${this._seconds}
|
||||
@value-changed=${this._secondsChanged}
|
||||
></ha-numeric-arrow-input>
|
||||
`
|
||||
: nothing}
|
||||
${this._useAmPm
|
||||
? html`
|
||||
<ha-button @click=${this._toggleAmPm}>
|
||||
${this._isPm ? "PM" : "AM"}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("_hours")) {
|
||||
this._timeUpdated();
|
||||
}
|
||||
|
||||
if (changedProperties.has("_minutes")) {
|
||||
this._timeUpdated();
|
||||
}
|
||||
|
||||
if (changedProperties.has("_seconds")) {
|
||||
this._timeUpdated();
|
||||
}
|
||||
|
||||
if (changedProperties.has("_useAmPm")) {
|
||||
this._timeUpdated();
|
||||
}
|
||||
|
||||
if (changedProperties.has("_isPm")) {
|
||||
this._timeUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
private _hoursChanged(ev: CustomEvent<ClampedValue>) {
|
||||
ev.stopPropagation?.();
|
||||
this._hours = ev.detail.value;
|
||||
}
|
||||
|
||||
private _minutesChanged(ev: CustomEvent<ClampedValue>) {
|
||||
ev.stopPropagation?.();
|
||||
this._minutes = ev.detail.value;
|
||||
if (ev.detail.clamped) {
|
||||
if (ev.detail.value === 0) {
|
||||
this._hoursChanged({
|
||||
detail: clampValue({
|
||||
value: this._hours - 1,
|
||||
min: this._useAmPm ? 1 : 0,
|
||||
max: this._useAmPm ? 12 : 23,
|
||||
}),
|
||||
} as CustomEvent<ClampedValue>);
|
||||
this._minutes = 59;
|
||||
}
|
||||
|
||||
if (ev.detail.value === 59) {
|
||||
this._hoursChanged({
|
||||
detail: clampValue({
|
||||
value: this._hours + 1,
|
||||
min: this._useAmPm ? 1 : 0,
|
||||
max: this._useAmPm ? 12 : 23,
|
||||
}),
|
||||
} as CustomEvent<ClampedValue>);
|
||||
const hourMax = this._useAmPm ? 12 : 23;
|
||||
if (this._hours < hourMax) {
|
||||
this._minutes = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _secondsChanged(ev: CustomEvent<ClampedValue>) {
|
||||
ev.stopPropagation?.();
|
||||
this._seconds = ev.detail.value;
|
||||
if (ev.detail.clamped) {
|
||||
if (ev.detail.value === 0) {
|
||||
this._minutesChanged({
|
||||
detail: clampValue({ value: this._minutes - 1, min: 0, max: 59 }),
|
||||
} as CustomEvent<ClampedValue>);
|
||||
this._seconds = 59;
|
||||
}
|
||||
|
||||
if (ev.detail.value === 59) {
|
||||
this._minutesChanged({
|
||||
detail: clampValue({ value: this._minutes + 1, min: 0, max: 59 }),
|
||||
} as CustomEvent<ClampedValue>);
|
||||
const hourMax = this._useAmPm ? 12 : 23;
|
||||
if (!(this._hours === hourMax && this._minutes === 59)) {
|
||||
this._seconds = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleAmPm() {
|
||||
this._isPm = !this._isPm;
|
||||
}
|
||||
|
||||
private _timeUpdated() {
|
||||
let hour24 = this._hours;
|
||||
|
||||
if (this._useAmPm) {
|
||||
if (this._hours === 12) {
|
||||
hour24 = this._isPm ? 12 : 0;
|
||||
} else {
|
||||
hour24 = this._isPm ? this._hours + 12 : this._hours;
|
||||
}
|
||||
}
|
||||
|
||||
const timeParts = [
|
||||
hour24.toString().padStart(2, "0"),
|
||||
this._minutes.toString().padStart(2, "0"),
|
||||
this._seconds.toString().padStart(2, "0"),
|
||||
];
|
||||
|
||||
const time = timeParts.join(":");
|
||||
if (time === this.value) {
|
||||
return;
|
||||
}
|
||||
this.value = time;
|
||||
fireEvent(this, "change");
|
||||
fireEvent(this, "value-changed", { value: time });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.time-picker-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-picker-separator {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-time-picker": HaTimePicker;
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,35 @@ export const getNumberDeviceClassConvertibleUnits = (
|
||||
type: "number/device_class_convertible_units",
|
||||
device_class: deviceClass,
|
||||
});
|
||||
|
||||
export interface ClampedValue {
|
||||
clamped: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a value between a minimum and maximum value
|
||||
* @param value - The value to clamp
|
||||
* @param min - The minimum value
|
||||
* @param max - The maximum value
|
||||
* @returns The clamped value
|
||||
*/
|
||||
export const clampValue = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}): ClampedValue => {
|
||||
if (max !== undefined && value > max) {
|
||||
return { clamped: true, value: max };
|
||||
}
|
||||
|
||||
if (min !== undefined && value < min) {
|
||||
return { clamped: true, value: min };
|
||||
}
|
||||
|
||||
return { clamped: false, value };
|
||||
};
|
||||
|
||||
@@ -79,6 +79,13 @@ export const demoPanels: Panels = {
|
||||
config: null,
|
||||
url_path: "energy",
|
||||
},
|
||||
"time-picker": {
|
||||
component_name: "time-picker",
|
||||
icon: "hass:clock-outline",
|
||||
title: "time_picker",
|
||||
config: null,
|
||||
url_path: "time-picker",
|
||||
},
|
||||
// config: {
|
||||
// component_name: "config",
|
||||
// icon: "hass:cog",
|
||||
|
||||
@@ -30,6 +30,7 @@ const COMPONENTS = {
|
||||
my: () => import("../panels/my/ha-panel-my"),
|
||||
profile: () => import("../panels/profile/ha-panel-profile"),
|
||||
todo: () => import("../panels/todo/ha-panel-todo"),
|
||||
"time-picker": () => import("../panels/time-picker/ha-panel-time-picker"),
|
||||
"media-browser": () =>
|
||||
import("../panels/media-browser/ha-panel-media-browser"),
|
||||
};
|
||||
|
||||
@@ -54,6 +54,10 @@ class DeveloperToolsRouter extends HassRouterPage {
|
||||
tag: "developer-tools-debug",
|
||||
load: () => import("./debug/developer-tools-debug"),
|
||||
},
|
||||
"time-picker": {
|
||||
tag: "developer-tools-time-picker",
|
||||
load: () => import("../time-picker/ha-panel-time-picker"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -80,6 +80,13 @@ class PanelDeveloperTools extends LitElement {
|
||||
<sl-tab slot="nav" panel="assist" .active=${page === "assist"}
|
||||
>Assist</sl-tab
|
||||
>
|
||||
<sl-tab
|
||||
slot="nav"
|
||||
panel="time-picker"
|
||||
.active=${page === "time-picker"}
|
||||
>
|
||||
Time Picker
|
||||
</sl-tab>
|
||||
</sl-tab-group>
|
||||
</div>
|
||||
<developer-tools-router
|
||||
|
||||
180
src/panels/time-picker/ha-panel-time-picker.ts
Normal file
180
src/panels/time-picker/ha-panel-time-picker.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { html, LitElement, css } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../../components/ha-time-picker";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-selector/ha-selector";
|
||||
|
||||
@customElement("developer-tools-time-picker")
|
||||
export class DeveloperToolsTimePicker extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public narrow = false;
|
||||
|
||||
@state()
|
||||
private _timeValue = "14:15:00";
|
||||
|
||||
@state()
|
||||
private _timeValue2 = "09:05:05";
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--primary-text-color);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--primary-text-color);
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.example {
|
||||
background: var(--card-background-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.example h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--primary-text-color);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-picker-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.value-display {
|
||||
background: var(--secondary-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: var(--primary-text-color);
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-toggle {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.time-picker-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="header">
|
||||
<h1>Time picker demo</h1>
|
||||
<p>
|
||||
This page demonstrates the ha-time-picker component with various
|
||||
configurations and use cases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Time picker</h2>
|
||||
<div class="example">
|
||||
<div class="time-picker-container">
|
||||
<ha-time-picker
|
||||
.locale=${this.hass.locale}
|
||||
.value=${this._timeValue}
|
||||
@value-changed=${this._onTimeChanged}
|
||||
></ha-time-picker>
|
||||
<div class="value-display">${this._timeValue}</div>
|
||||
</div>
|
||||
<p>Current value: ${this._timeValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Time picker with seconds</h2>
|
||||
<div class="example">
|
||||
<div class="time-picker-container">
|
||||
<ha-time-picker
|
||||
.locale=${this.hass.locale}
|
||||
.value=${this._timeValue2}
|
||||
.enableSeconds=${true}
|
||||
@value-changed=${this._onTime2Changed}
|
||||
></ha-time-picker>
|
||||
<div class="value-display">${this._timeValue2}</div>
|
||||
</div>
|
||||
<p>Current value: ${this._timeValue2}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _onTimeChanged(ev: CustomEvent) {
|
||||
this._timeValue = ev.detail.value;
|
||||
}
|
||||
|
||||
private _onTime2Changed(ev: CustomEvent) {
|
||||
this._timeValue2 = ev.detail.value;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"developer-tools-time-picker": DeveloperToolsTimePicker;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user