forked from extern/egroupware
Get category icons & colors working for select category
Also some refactoring of things to where they should be
This commit is contained in:
parent
2a79264674
commit
260d8f523a
@ -44,7 +44,9 @@ foreach($categories as $cat)
|
||||
{
|
||||
// Use slightly more specific selector that just class, to allow defaults
|
||||
// if the category has no color
|
||||
$content .= ".egwGridView_scrollarea tr.row_category.cat_{$cat['id']} > td:first-child, .select-cat li.cat_{$cat['id']}, .et2_selectbox ul.chzn-results li.cat_{$cat['id']}, .et2_selectbox ul.chzn-choices li.cat_{$cat['id']}, .nextmatch_header_row .et2_selectbox.select-cat.cat_{$cat['id']} a.chzn-single , et2-select-cat > .cat_{$cat['id']} {border-left-color: {$cat['data']['color']};} .cat_{$cat['id']}.fullline_cat_bg, div.cat_{$cat['id']}, span.cat_{$cat['id']} { background-color: {$cat['data']['color']};} /*{$cat['name']}*/\n";
|
||||
$content .= "/** {$cat['name']} **/\n/*webComponent*/\n";
|
||||
$content .= ":root,:host {--cat-{$cat['id']}-color: {$cat['data']['color']};}, :host.cat_{$cat['id']}, .cat_{$cat['id']} {--category-color: {$cat['data']['color']};} et2-select-cat > .cat_{$cat['id']} {--category-color: {$cat['data']['color']};} \n";
|
||||
$content .= "/*legacy*/\n.egwGridView_scrollarea tr.row_category.cat_{$cat['id']} > td:first-child, .select-cat li.cat_{$cat['id']}, .et2_selectbox ul.chzn-results li.cat_{$cat['id']}, .et2_selectbox ul.chzn-choices li.cat_{$cat['id']}, .nextmatch_header_row .et2_selectbox.select-cat.cat_{$cat['id']} a.chzn-single , .cat_{$cat['id']}.fullline_cat_bg, div.cat_{$cat['id']}, span.cat_{$cat['id']} { background-color: {$cat['data']['color']};} \n";
|
||||
}
|
||||
if (!empty($cat['data']['icon']))
|
||||
{
|
||||
|
@ -13,10 +13,11 @@ import {StaticOptions} from "./StaticOptions";
|
||||
import {Et2widgetWithSelectMixin} from "./Et2WidgetWithSelectMixin";
|
||||
import {SelectOption} from "./FindSelectOptions";
|
||||
import {Et2InvokerMixin} from "../Et2Url/Et2InvokerMixin";
|
||||
import {SlSelect} from "@shoelace-style/shoelace";
|
||||
import {SlMenuItem, SlSelect} from "@shoelace-style/shoelace";
|
||||
import {egw} from "../../jsapi/egw_global";
|
||||
import shoelace from "../Styles/shoelace";
|
||||
import {Et2WithSearchMixin} from "./SearchMixin";
|
||||
import {Et2Tag} from "./Tag/Et2Tag";
|
||||
|
||||
// export Et2WidgetWithSelect which is used as type in other modules
|
||||
export class Et2WidgetWithSelect extends Et2widgetWithSelectMixin(SlSelect)
|
||||
@ -49,12 +50,33 @@ export class Et2Select extends Et2WithSearchMixin(Et2InvokerMixin(Et2WidgetWithS
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Get rid of padding before/after options */
|
||||
sl-menu::part(base) {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
/* Avoid double scrollbar if there are a lot of options */
|
||||
.select__menu
|
||||
{
|
||||
max-height: initial;
|
||||
}
|
||||
|
||||
/** multiple=true uses tags for each value **/
|
||||
/* styling for icon inside tag (not option) */
|
||||
.tag_image {
|
||||
margin-right: var(--sl-spacing-x-small);
|
||||
}
|
||||
/* Maximum height + scrollbar on tags (+ other styling) */
|
||||
.select__tags {
|
||||
max-height: 5em;
|
||||
overflow-y: auto;
|
||||
|
||||
gap: 0.1rem 0.5rem;
|
||||
}
|
||||
/* Keep overflow tag right-aligned. It's the only sl-tag. */
|
||||
.select__tags sl-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
select:hover {
|
||||
box-shadow: 1px 1px 1px rgb(0 0 0 / 60%);
|
||||
}`
|
||||
@ -260,16 +282,48 @@ export class Et2Select extends Et2WithSearchMixin(Et2InvokerMixin(Et2WidgetWithS
|
||||
}
|
||||
|
||||
// propagate multiple to selectbox
|
||||
if (changedProperties.has('multiple'))
|
||||
if(changedProperties.has('multiple'))
|
||||
{
|
||||
// switch the expand button off
|
||||
if (this.multiple)
|
||||
if(this.multiple)
|
||||
{
|
||||
this.expand_multiple_rows = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method from SlSelect to stick our own tags in there
|
||||
*/
|
||||
syncItemsFromValue()
|
||||
{
|
||||
if(typeof super.syncItemsFromValue === "function")
|
||||
{
|
||||
super.syncItemsFromValue();
|
||||
}
|
||||
|
||||
// Only applies to multiple
|
||||
if(typeof this.displayTags !== "object" || !this.multiple)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let overflow = null;
|
||||
if(this.maxTagsVisible > 0 && this.displayTags.length > this.maxTagsVisible)
|
||||
{
|
||||
overflow = this.displayTags.pop();
|
||||
}
|
||||
const checkedItems = Object.values(this.menuItems).filter(item => this.value.includes(item.value));
|
||||
this.displayTags = checkedItems.map(item => this._createTagNode(item));
|
||||
|
||||
// Re-slice & add overflow tag
|
||||
if(overflow)
|
||||
{
|
||||
this.displayTags = this.displayTags.slice(0, this.maxTagsVisible);
|
||||
this.displayTags.push(overflow);
|
||||
}
|
||||
}
|
||||
|
||||
_emptyLabelTemplate() : TemplateResult
|
||||
{
|
||||
if(!this.empty_label || this.multiple)
|
||||
@ -280,18 +334,93 @@ export class Et2Select extends Et2WithSearchMixin(Et2InvokerMixin(Et2WidgetWithS
|
||||
<sl-menu-item value="">${this.empty_label}</sl-menu-item>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag used for rendering options
|
||||
* Used for finding & filtering options, they're created by the mixed-in class
|
||||
* @returns {string}
|
||||
*/
|
||||
public get optionTag()
|
||||
{
|
||||
return "sl-menu-item";
|
||||
}
|
||||
|
||||
|
||||
_optionTemplate(option : SelectOption) : TemplateResult
|
||||
{
|
||||
let icon = option.icon ? html`
|
||||
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)"
|
||||
src="${option.icon}"></et2-image>` : "";
|
||||
|
||||
// Tag used must match this.optionTag, but you can't use the variable directly
|
||||
return html`
|
||||
<sl-menu-item value="${option.value}" title="${option.title}" class="${option.class}">
|
||||
${icon}
|
||||
${option.label}
|
||||
</sl-menu-item>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag used for rendering tags when multiple=true
|
||||
* Used for creating, finding & filtering options.
|
||||
* @see createTagNode()
|
||||
* @returns {string}
|
||||
*/
|
||||
public get tagTag()
|
||||
{
|
||||
return "et2-tag";
|
||||
}
|
||||
|
||||
/**
|
||||
* Customise how tags are rendered. This overrides what SlSelect
|
||||
* does in syncItemsFromValue().
|
||||
* This is a copy+paste from SlSelect.syncItemsFromValue().
|
||||
*
|
||||
* @param item
|
||||
* @protected
|
||||
*/
|
||||
protected _createTagNode(item)
|
||||
{
|
||||
const tag = <Et2Tag>document.createElement(this.tagTag);
|
||||
tag.value = item.value;
|
||||
tag.textContent = this.getItemLabel(item);
|
||||
tag.class = item.classList.value + " search_tag";
|
||||
tag.addEventListener("dblclick", this._handleDoubleClick);
|
||||
tag.addEventListener("click", this.handleTagInteraction);
|
||||
tag.addEventListener("keydown", this.handleTagInteraction);
|
||||
tag.addEventListener("sl-remove", (event) =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
if(!this.disabled)
|
||||
{
|
||||
item.checked = false;
|
||||
this.syncValueFromItems();
|
||||
}
|
||||
});
|
||||
let image = this._createImage(item);
|
||||
if(image)
|
||||
{
|
||||
tag.prepend(image);
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
protected _createImage(item)
|
||||
{
|
||||
let image = item.querySelector("et2-image");
|
||||
if(image)
|
||||
{
|
||||
image = image.clone();
|
||||
image.slot = "prefix";
|
||||
image.class = "tag_image";
|
||||
return image;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public get menuItems() : HTMLElement[]
|
||||
{
|
||||
return [...this.querySelectorAll<SlMenuItem>(this.optionTag)];
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("et2-select", Et2Select);
|
||||
@ -359,9 +488,13 @@ export class Et2SelectCategory extends Et2Select
|
||||
return [
|
||||
...super.styles,
|
||||
css`
|
||||
::slotted(*) {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
/* Category color on options */
|
||||
::slotted(*) {
|
||||
border-left: 3px solid var(--category-color, transparent);
|
||||
}
|
||||
.select--standard .select__control {
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
`
|
||||
]
|
||||
}
|
||||
@ -391,6 +524,71 @@ export class Et2SelectCategory extends Et2Select
|
||||
|
||||
this.select_options = so.cat(this);
|
||||
}
|
||||
|
||||
connectedCallback()
|
||||
{
|
||||
super.connectedCallback();
|
||||
|
||||
if(typeof this.application == 'undefined')
|
||||
{
|
||||
this.application =
|
||||
// When the widget is first created, it doesn't have a parent and can't find it's instanceManager
|
||||
(this.getInstanceManager() && this.getInstanceManager().app) ||
|
||||
this.egw().app_name();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updated(changedProperties : PropertyValues)
|
||||
{
|
||||
super.updated(changedProperties);
|
||||
|
||||
if(changedProperties.has("value"))
|
||||
{
|
||||
this.doLabelChange()
|
||||
}
|
||||
}
|
||||
|
||||
doLabelChange()
|
||||
{
|
||||
// Update the display label when checked menu item's label changes
|
||||
if(this.multiple)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const checkedItem = this.menuItems.find(item => item.value === this.value);
|
||||
this.displayLabel = checkedItem ? checkedItem.textContent : '';
|
||||
this.querySelector("[slot=prefix].tag_image")?.remove();
|
||||
if(checkedItem)
|
||||
{
|
||||
let image = this._createImage(checkedItem)
|
||||
if(image)
|
||||
{
|
||||
this.append(image);
|
||||
}
|
||||
this.dropdown.querySelector(".select__control").style.borderColor =
|
||||
getComputedStyle(checkedItem).getPropertyValue("--category-color") || "transparent";
|
||||
}
|
||||
}
|
||||
|
||||
get tagTag() : string
|
||||
{
|
||||
return "et2-category-tag";
|
||||
}
|
||||
|
||||
/**
|
||||
* Customise how tags are rendered. This overrides parent to set application
|
||||
*
|
||||
* @param item
|
||||
* @protected
|
||||
*/
|
||||
protected _createTagNode(item)
|
||||
{
|
||||
let tag = super._createTagNode(item);
|
||||
tag.application = this.application;
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
|
||||
|
@ -9,7 +9,7 @@
|
||||
|
||||
import {Et2Select} from "./Et2Select";
|
||||
import {SelectOption} from "./FindSelectOptions";
|
||||
import {html} from "@lion/core";
|
||||
import {Et2Image} from "../Et2Image/Et2Image";
|
||||
|
||||
export type AccountType = 'accounts'|'groups'|'both'|'owngroups';
|
||||
|
||||
@ -90,11 +90,11 @@ export class Et2SelectAccount extends Et2Select
|
||||
* @protected
|
||||
*
|
||||
*/
|
||||
protected _tagImageTemplate(item)
|
||||
protected _createImage(item) : Et2Image
|
||||
{
|
||||
return html`
|
||||
<et2-image slot="prefix" part="icon"
|
||||
src="/egroupware/api/avatar.php?account_id=${item.value}&etag=1"></et2-image>`;
|
||||
const image = super._createImage(item);
|
||||
image.src = "/egroupware/api/avatar.php?account_id=" + item.value + "&etag=1";
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -29,6 +29,12 @@ export class Et2SelectEmail extends Et2Select
|
||||
::slotted(sl-icon[slot="suffix"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide selected options from the dropdown */
|
||||
::slotted([checked])
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
|
@ -142,10 +142,6 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
||||
:host([allowFreeEntries]) ::slotted([slot="suffix"]) {
|
||||
display: none;
|
||||
}
|
||||
/* Get rid of padding before/after options */
|
||||
sl-menu::part(base) {
|
||||
padding: 0px;
|
||||
}
|
||||
/* Make search textbox take full width */
|
||||
::slotted(.search_input), ::slotted(.search_input) input, .search_input, .search_input input {
|
||||
width: 100%;
|
||||
@ -195,30 +191,10 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
||||
::slotted(.no-match) {
|
||||
display: none;
|
||||
}
|
||||
/* Hide selected options from the dropdown */
|
||||
::slotted([checked])
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
/* Different cursor for editable tags */
|
||||
:host([allowfreeentries]) .search_tag::part(base) {
|
||||
cursor: text;
|
||||
}
|
||||
/* styling for icon inside tag (not option) */
|
||||
.tag_image {
|
||||
margin-right: var(--sl-spacing-x-small);
|
||||
}
|
||||
/* Maximum height + scrollbar on tags (+ other styling) */
|
||||
.select__tags {
|
||||
max-height: 5em;
|
||||
overflow-y: auto;
|
||||
|
||||
gap: 0.1rem 0.5rem;
|
||||
}
|
||||
/* Keep overflow tag right-aligned. It's the only sl-tag. */
|
||||
.select__tags sl-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
`
|
||||
]
|
||||
}
|
||||
@ -337,7 +313,7 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
||||
|
||||
protected _searchInputTemplate()
|
||||
{
|
||||
let edit = '';
|
||||
let edit = null;
|
||||
if(this.editModeEnabled)
|
||||
{
|
||||
edit = html`<input id="edit" type="text" part="input"
|
||||
@ -385,20 +361,6 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
||||
this.querySelector(".search_input");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag used for rendering options
|
||||
* Used for finding & filtering options, they're created by the mixed-in class
|
||||
* @returns {string}
|
||||
*/
|
||||
public get optionTag()
|
||||
{
|
||||
return "sl-menu-item";
|
||||
}
|
||||
|
||||
protected get menuItems()
|
||||
{
|
||||
return this.querySelectorAll(this.optionTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only local options, excludes server options
|
||||
@ -432,7 +394,7 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
||||
// Overridden to add options if allowFreeEntries=true
|
||||
if(this.allowFreeEntries)
|
||||
{
|
||||
if(typeof this.value == "string" && !Object.values(this.menuItems).find(o => o.value == this.value))
|
||||
if(typeof this.value == "string" && !this.menuItems.find(o => o.value == this.value))
|
||||
{
|
||||
this.createFreeEntry(this.value);
|
||||
}
|
||||
@ -440,7 +402,7 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
||||
{
|
||||
this.value.forEach((e) =>
|
||||
{
|
||||
if(!Object.values(this.menuItems).find(o => o.value == e))
|
||||
if(!this.menuItems.find(o => o.value == e))
|
||||
{
|
||||
this.createFreeEntry(e);
|
||||
}
|
||||
@ -856,85 +818,6 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
||||
this.handleMenuShow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method from SlSelect to stick our own tags in there
|
||||
*/
|
||||
syncItemsFromValue()
|
||||
{
|
||||
if(typeof super.syncItemsFromValue === "function")
|
||||
{
|
||||
super.syncItemsFromValue();
|
||||
}
|
||||
|
||||
// Only applies to multiple
|
||||
if(typeof this.displayTags !== "object" || !this.multiple)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let overflow = null;
|
||||
if(this.maxTagsVisible > 0 && this.displayTags.length > this.maxTagsVisible)
|
||||
{
|
||||
overflow = this.displayTags.pop();
|
||||
}
|
||||
const checkedItems = Object.values(this.menuItems).filter(item => this.value.includes(item.value));
|
||||
this.displayTags = checkedItems.map(item => this._tagTemplate(item));
|
||||
|
||||
// Re-slice & add overflow tag
|
||||
if(overflow)
|
||||
{
|
||||
this.displayTags = this.displayTags.slice(0, this.maxTagsVisible);
|
||||
this.displayTags.push(overflow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Customise how tags are rendered. This overrides what SlSelect
|
||||
* does in syncItemsFromValue().
|
||||
* This is a copy+paste from SlSelect.syncItemsFromValue().
|
||||
*
|
||||
* @param item
|
||||
* @protected
|
||||
*/
|
||||
protected _tagTemplate(item)
|
||||
{
|
||||
return html`
|
||||
<et2-tag class="${item.classList.value} search_tag"
|
||||
removable
|
||||
value="${item.value}"
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
@click=${this.handleTagInteraction}
|
||||
@keydown=${this.handleTagInteraction}
|
||||
@sl-remove=${(event) =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
if(!this.disabled)
|
||||
{
|
||||
item.checked = false;
|
||||
this.syncValueFromItems();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${this._tagImageTemplate(item)}
|
||||
${this.getItemLabel(item)}
|
||||
</et2-tag>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _tagImageTemplate(item)
|
||||
{
|
||||
let image = item.querySelector("et2-image");
|
||||
if(image)
|
||||
{
|
||||
image = image.clone();
|
||||
image.slot = "prefix";
|
||||
image.class = "tag_image";
|
||||
return image;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
protected _handleSearchAbort(e)
|
||||
{
|
||||
this._activeControls.classList.remove("active");
|
||||
|
54
api/js/etemplate/Et2Select/Tag/Et2CategoryTag.ts
Normal file
54
api/js/etemplate/Et2Select/Tag/Et2CategoryTag.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* EGroupware eTemplate2 - Category Tag WebComponent
|
||||
*
|
||||
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
|
||||
* @package api
|
||||
* @link https://www.egroupware.org
|
||||
* @author Nathan Gray
|
||||
*/
|
||||
import {css, html, TemplateResult} from "@lion/core";
|
||||
import shoelace from "../../Styles/shoelace";
|
||||
import {Et2Tag} from "./Et2Tag";
|
||||
|
||||
/**
|
||||
* Tag is usually used in a Et2CategorySelect with multiple=true, but there's no reason it can't go anywhere
|
||||
*/
|
||||
export class Et2CategoryTag extends Et2Tag
|
||||
{
|
||||
private value : string;
|
||||
|
||||
static get styles()
|
||||
{
|
||||
return [
|
||||
super.styles,
|
||||
shoelace, css`
|
||||
.tag {
|
||||
gap: var(--sl-spacing-2x-small);
|
||||
/* --category-color is passed through in _styleTemplate() */
|
||||
border-left: 5px solid var(--category-color, transparent);
|
||||
}
|
||||
`];
|
||||
}
|
||||
|
||||
constructor(...args : [])
|
||||
{
|
||||
super(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Due to how the scoping / encapulation works, we need to re-assign the category color
|
||||
* variable here so it can be passed through. .cat_# {--category-color} is not visible.
|
||||
*
|
||||
* @returns {TemplateResult}
|
||||
* @protected
|
||||
*/
|
||||
protected _styleTemplate() : TemplateResult
|
||||
{
|
||||
let cat_var = "var(--cat-" + this.value + "-color)"
|
||||
// @formatter:off
|
||||
return html`<style>.tag { --category-color: ${cat_var}}</style>`;
|
||||
//@formatter:on
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("et2-category-tag", Et2CategoryTag);
|
@ -8,7 +8,7 @@
|
||||
*/
|
||||
import {Et2Widget} from "../../Et2Widget/Et2Widget";
|
||||
import {SlTag} from "@shoelace-style/shoelace";
|
||||
import {classMap, css, html} from "@lion/core";
|
||||
import {classMap, css, html, TemplateResult} from "@lion/core";
|
||||
import shoelace from "../../Styles/shoelace";
|
||||
|
||||
/**
|
||||
@ -36,15 +36,31 @@ export class Et2Tag extends Et2Widget(SlTag)
|
||||
`];
|
||||
}
|
||||
|
||||
static get properties()
|
||||
{
|
||||
return {
|
||||
...super.properties,
|
||||
value: {type: String, reflect: true}
|
||||
}
|
||||
}
|
||||
|
||||
constructor(...args : [])
|
||||
{
|
||||
super(...args);
|
||||
this.value = "";
|
||||
this.pill = true;
|
||||
this.removable = true;
|
||||
}
|
||||
|
||||
protected _styleTemplate() : TemplateResult
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
return html`
|
||||
${this._styleTemplate()}
|
||||
<span
|
||||
part="base"
|
||||
class=${classMap({
|
||||
|
@ -54,6 +54,7 @@ import './Et2Select/Et2SelectAccount';
|
||||
import './Et2Select/Et2SelectEmail';
|
||||
import './Et2Select/Et2SelectReadonly';
|
||||
import './Et2Select/Tag/Et2Tag';
|
||||
import './Et2Select/Tag/Et2CategoryTag'
|
||||
import './Et2Textarea/Et2Textarea';
|
||||
import './Et2Textbox/Et2Textbox';
|
||||
import './Et2Textbox/Et2TextboxReadonly';
|
||||
|
Loading…
Reference in New Issue
Block a user