Added editor functionallity + propper enum rendering
This commit is contained in:
@@ -23,7 +23,8 @@ builder.Services.AddHopFrame(config => {
|
|||||||
table.SetDescription("The user dataset. It contains all information for the users of the application.");
|
table.SetDescription("The user dataset. It contains all information for the users of the application.");
|
||||||
|
|
||||||
table.Property(u => u.Password)
|
table.Property(u => u.Password)
|
||||||
.Listable(false);
|
.Listable(false)
|
||||||
|
.SetType(PropertyType.Password);
|
||||||
|
|
||||||
table.SetPreferredProperty(u => u.Username);
|
table.SetPreferredProperty(u => u.Username);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ internal static class ConfigurationHelper {
|
|||||||
Table = table
|
Table = table
|
||||||
};
|
};
|
||||||
|
|
||||||
if (property.CustomAttributes.Any(a => a.AttributeType == typeof(KeyAttribute)))
|
if (property.CustomAttributes.Any(a => a.AttributeType == typeof(KeyAttribute))) {
|
||||||
table.PreferredProperty = config.Identifier;
|
table.PreferredProperty = config.Identifier;
|
||||||
|
config.Editable = false;
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ namespace HopFrame.Core.Services;
|
|||||||
public interface IEntityAccessor {
|
public interface IEntityAccessor {
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the formatted content of the property, ready to be displayed
|
/// Returns the formatted value of the property, ready to be displayed
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="model">The model to pull the property from</param>
|
/// <param name="model">The model to pull the property from</param>
|
||||||
/// <param name="property">The property that shall be extracted</param>
|
/// <param name="property">The property that shall be extracted</param>
|
||||||
public string? GetValue(object model, PropertyConfig property);
|
public string? GetValue(object model, PropertyConfig property);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the real value of the property, ready to be displayed
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="model">The model to pull the property from</param>
|
||||||
|
/// <param name="property">The property that shall be extracted</param>
|
||||||
|
public object? GetValueRaw(object model, PropertyConfig property);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Formats the property to be displayed properly
|
/// Formats the property to be displayed properly
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -25,7 +32,7 @@ public interface IEntityAccessor {
|
|||||||
/// <param name="model">The model to save the property to</param>
|
/// <param name="model">The model to save the property to</param>
|
||||||
/// <param name="property">The property that shall be modified</param>
|
/// <param name="property">The property that shall be modified</param>
|
||||||
/// <param name="value">The new value of the property</param>
|
/// <param name="value">The new value of the property</param>
|
||||||
public void SetValue(object model, PropertyConfig property, object value);
|
public void SetValue(object model, PropertyConfig property, object? value);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sorts the provided dataset by the specified property
|
/// Sorts the provided dataset by the specified property
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ namespace HopFrame.Core.Services.Implementation;
|
|||||||
internal class EntityAccessor(IConfigAccessor accessor) : IEntityAccessor {
|
internal class EntityAccessor(IConfigAccessor accessor) : IEntityAccessor {
|
||||||
|
|
||||||
public string? GetValue(object model, PropertyConfig property) {
|
public string? GetValue(object model, PropertyConfig property) {
|
||||||
|
var value = GetValueRaw(model, property);
|
||||||
|
if (value is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return FormatValue(value, property);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? GetValueRaw(object model, PropertyConfig property) {
|
||||||
var prop = model.GetType().GetProperty(property.Identifier);
|
var prop = model.GetType().GetProperty(property.Identifier);
|
||||||
if (prop is null)
|
if (prop is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var value = prop.GetValue(model);
|
return prop.GetValue(model);
|
||||||
return FormatValue(value, property);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? FormatValue(object? value, PropertyConfig property) {
|
public string? FormatValue(object? value, PropertyConfig property) {
|
||||||
@@ -34,12 +41,12 @@ internal class EntityAccessor(IConfigAccessor accessor) : IEntityAccessor {
|
|||||||
return value.ToString();
|
return value.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetValue(object model, PropertyConfig property, object value) {
|
public void SetValue(object model, PropertyConfig property, object? value) {
|
||||||
var prop = model.GetType().GetProperty(property.Identifier);
|
var prop = model.GetType().GetProperty(property.Identifier);
|
||||||
if (prop is null)
|
if (prop is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (value.GetType() != property.Type)
|
if (value?.GetType() != property.Type)
|
||||||
value = Convert.ChangeType(value, property.Type);
|
value = Convert.ChangeType(value, property.Type);
|
||||||
|
|
||||||
prop.SetValue(model, value);
|
prop.SetValue(model, value);
|
||||||
|
|||||||
@@ -18,13 +18,19 @@
|
|||||||
}
|
}
|
||||||
</MudText>
|
</MudText>
|
||||||
|
|
||||||
|
@if (Entry is not null) {
|
||||||
<MudStack Spacing="5" Style="overflow-y: auto">
|
<MudStack Spacing="5" Style="overflow-y: auto">
|
||||||
<MudFocusTrap>
|
<MudFocusTrap>
|
||||||
@foreach (var property in GetProperties()) {
|
@foreach (var property in GetProperties()) {
|
||||||
<PropertyInput Config="property" Variant="Variant.Filled" />
|
<PropertyInput
|
||||||
|
Config="property"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Value="@(GetPropertyValue(property))"
|
||||||
|
ValueChanged="@(v => OnPropertyUpdated(property, v))"/>
|
||||||
}
|
}
|
||||||
</MudFocusTrap>
|
</MudFocusTrap>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
}
|
||||||
|
|
||||||
<MudStack Row="true" Style="margin-top: auto">
|
<MudStack Row="true" Style="margin-top: auto">
|
||||||
<MudButton Color="Color.Primary" OnClick="@(Submit)">Save</MudButton>
|
<MudButton Color="Color.Primary" OnClick="@(Submit)">Save</MudButton>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using HopFrame.Core.Configuration;
|
using HopFrame.Core.Configuration;
|
||||||
|
using HopFrame.Core.Services;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using MudBlazor;
|
using MudBlazor;
|
||||||
|
|
||||||
namespace HopFrame.Web.Components.Components;
|
namespace HopFrame.Web.Components.Components;
|
||||||
|
|
||||||
public partial class Editor(IDialogService dialogs) : ComponentBase {
|
public partial class Editor(IDialogService dialogs, IEntityAccessor accessor) : ComponentBase {
|
||||||
|
|
||||||
private enum EditorMode {
|
private enum EditorMode {
|
||||||
Editor,
|
Editor,
|
||||||
@@ -22,10 +23,13 @@ public partial class Editor(IDialogService dialogs) : ComponentBase {
|
|||||||
|
|
||||||
private TaskCompletionSource<object?> Completion { get; set; } = null!;
|
private TaskCompletionSource<object?> Completion { get; set; } = null!;
|
||||||
|
|
||||||
|
private Dictionary<string, object?> UpdatedValues { get; set; } = null!;
|
||||||
|
|
||||||
public Task<object?> Present(object? entry) {
|
public Task<object?> Present(object? entry) {
|
||||||
Completion = new ();
|
Completion = new ();
|
||||||
Mode = entry is null ? EditorMode.Creator : EditorMode.Editor;
|
Mode = entry is null ? EditorMode.Creator : EditorMode.Editor;
|
||||||
Entry = entry ?? Activator.CreateInstance(Config.TableType);
|
Entry = entry ?? Activator.CreateInstance(Config.TableType)!;
|
||||||
|
UpdatedValues = new();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
IsVisible = true;
|
IsVisible = true;
|
||||||
return Completion.Task;
|
return Completion.Task;
|
||||||
@@ -56,8 +60,19 @@ public partial class Editor(IDialogService dialogs) : ComponentBase {
|
|||||||
return query.OrderBy(p => p.OrderIndex);
|
return query.OrderBy(p => p.OrderIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private object? GetPropertyValue(PropertyConfig property) {
|
||||||
|
return accessor.GetValueRaw(Entry!, property);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyUpdated(PropertyConfig property, object? value) {
|
||||||
|
UpdatedValues[property.Identifier] = value;
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyChanges() {
|
private void ApplyChanges() {
|
||||||
|
foreach (var propUpdate in UpdatedValues) {
|
||||||
|
var property = Config.Properties.First(p => p.Identifier == propUpdate.Key);
|
||||||
|
accessor.SetValue(Entry!, property, propUpdate.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
@using HopFrame.Core.Configuration
|
@using System.Collections
|
||||||
|
@using HopFrame.Core.Configuration
|
||||||
|
|
||||||
@switch ((PropertyType)((byte)Config.PropertyType & 0x0F)) {
|
@switch ((PropertyType)((byte)Config.PropertyType & 0x0F)) {
|
||||||
case PropertyType.Numeric:
|
case PropertyType.Numeric:
|
||||||
<MudNumericField
|
<MudNumericField
|
||||||
T="double"
|
T="double"
|
||||||
|
Value="@(Convert.ToDouble(Value))"
|
||||||
|
ValueChanged="@(v => OnValueChanged(v))"
|
||||||
Label="@Config.DisplayName"
|
Label="@Config.DisplayName"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"/>
|
||||||
break;
|
break;
|
||||||
@@ -13,8 +16,9 @@
|
|||||||
case PropertyType.Boolean:
|
case PropertyType.Boolean:
|
||||||
<MudSwitch
|
<MudSwitch
|
||||||
T="bool"
|
T="bool"
|
||||||
|
Value="@((bool)(Value ?? false))"
|
||||||
|
ValueChanged="@(v => OnValueChanged(v))"
|
||||||
Label="@Config.DisplayName"
|
Label="@Config.DisplayName"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
|
||||||
Disabled="@(!Config.Editable)"/>
|
Disabled="@(!Config.Editable)"/>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -22,14 +26,18 @@
|
|||||||
<MudField Label="@Config.DisplayName" Variant="Variant.Outlined" Style="display: flex">
|
<MudField Label="@Config.DisplayName" Variant="Variant.Outlined" Style="display: flex">
|
||||||
<MudStack Row="true">
|
<MudStack Row="true">
|
||||||
<MudDatePicker
|
<MudDatePicker
|
||||||
|
Date="_date"
|
||||||
|
DateChanged="@(v => OnValueChanged(v))"
|
||||||
Label="Date"
|
Label="Date"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"/>
|
||||||
|
|
||||||
<MudTimePicker
|
<MudTimePicker
|
||||||
|
Time="_time"
|
||||||
|
TimeChanged="@(v => OnValueChanged(v))"
|
||||||
Label="Time"
|
Label="Time"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"/>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
@@ -38,16 +46,20 @@
|
|||||||
|
|
||||||
case PropertyType.DateOnly:
|
case PropertyType.DateOnly:
|
||||||
<MudDatePicker
|
<MudDatePicker
|
||||||
|
Date="_date"
|
||||||
|
DateChanged="@(v => OnValueChanged(v))"
|
||||||
Label="@Config.DisplayName"
|
Label="@Config.DisplayName"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"/>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PropertyType.TimeOnly:
|
case PropertyType.TimeOnly:
|
||||||
<MudTimePicker
|
<MudTimePicker
|
||||||
|
Time="_time"
|
||||||
|
TimeChanged="@(v => OnValueChanged(v))"
|
||||||
Label="@Config.DisplayName"
|
Label="@Config.DisplayName"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"/>
|
||||||
break;
|
break;
|
||||||
@@ -55,9 +67,11 @@
|
|||||||
case PropertyType.Email:
|
case PropertyType.Email:
|
||||||
<MudTextField
|
<MudTextField
|
||||||
T="string"
|
T="string"
|
||||||
|
Value="Value?.ToString()"
|
||||||
|
ValueChanged="@(v => OnValueChanged(v))"
|
||||||
InputType="InputType.Email"
|
InputType="InputType.Email"
|
||||||
Label="@Config.DisplayName"
|
Label="@Config.DisplayName"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"/>
|
||||||
break;
|
break;
|
||||||
@@ -65,28 +79,61 @@
|
|||||||
case PropertyType.Password:
|
case PropertyType.Password:
|
||||||
<MudTextField
|
<MudTextField
|
||||||
T="string"
|
T="string"
|
||||||
InputType="InputType.Password"
|
Value="Value?.ToString()"
|
||||||
|
ValueChanged="@(v => OnValueChanged(v))"
|
||||||
|
InputType="@_passwordInputType"
|
||||||
Label="@Config.DisplayName"
|
Label="@Config.DisplayName"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"
|
||||||
|
Adornment="Adornment.End"
|
||||||
|
AdornmentIcon="@_passwordIcon"
|
||||||
|
OnAdornmentClick="@(OnPasswordIconClick)"/>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PropertyType.PhoneNumber:
|
case PropertyType.PhoneNumber:
|
||||||
<MudTextField
|
<MudTextField
|
||||||
T="string"
|
T="string"
|
||||||
|
Value="Value?.ToString()"
|
||||||
|
ValueChanged="@(v => OnValueChanged(v))"
|
||||||
InputType="InputType.Telephone"
|
InputType="InputType.Telephone"
|
||||||
Label="@Config.DisplayName"
|
Label="@Config.DisplayName"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"/>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case PropertyType.Enum:
|
||||||
|
<MudSelect
|
||||||
|
T="object"
|
||||||
|
Value="Value?.ToString()"
|
||||||
|
ValueChanged="@(v => OnValueChanged(v))"
|
||||||
|
MultiSelection="@((Config.PropertyType & PropertyType.List) != 0)"
|
||||||
|
SelectedValuesChanged="@(v => OnValueChanged(v))"
|
||||||
|
Label="@Config.DisplayName"
|
||||||
|
Clearable="@((Config.PropertyType & (PropertyType.Nullable | PropertyType.List)) != 0)"
|
||||||
|
Disabled="@(!Config.Editable)"
|
||||||
|
Variant="Variant">
|
||||||
|
@if ((Config.PropertyType & PropertyType.List) != 0) {
|
||||||
|
@foreach (var value in Enum.GetValues(Config.Type.GenericTypeArguments[0])) {
|
||||||
|
<MudSelectItem Value="value">@Enum.GetName(Config.Type.GenericTypeArguments[0], value)</MudSelectItem>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
@foreach (var value in Enum.GetValues(Config.Type)) {
|
||||||
|
<MudSelectItem Value="value">@Enum.GetName(Config.Type, value)</MudSelectItem>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
<MudTextField
|
<MudTextField
|
||||||
T="string"
|
T="string"
|
||||||
|
Value="Value?.ToString()"
|
||||||
|
ValueChanged="@(v => OnValueChanged(v))"
|
||||||
Label="@Config.DisplayName"
|
Label="@Config.DisplayName"
|
||||||
Required="@((Config.PropertyType & PropertyType.Nullable) != 0)"
|
Required="@((Config.PropertyType & PropertyType.Nullable) == 0)"
|
||||||
Disabled="@(!Config.Editable)"
|
Disabled="@(!Config.Editable)"
|
||||||
Variant="Variant"/>
|
Variant="Variant"/>
|
||||||
break;
|
break;
|
||||||
@@ -95,7 +142,30 @@
|
|||||||
@code {
|
@code {
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public object? Value { get; set; }
|
public object? Value {
|
||||||
|
get;
|
||||||
|
set {
|
||||||
|
field = value;
|
||||||
|
|
||||||
|
if (value is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch ((PropertyType)((byte)Config.PropertyType & 0x0F)) {
|
||||||
|
case PropertyType.DateTime:
|
||||||
|
_date = (DateTime)value;
|
||||||
|
_time = TimeOnly.FromDateTime((DateTime)value).ToTimeSpan();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PropertyType.DateOnly:
|
||||||
|
_date = ((DateOnly)value).ToDateTime(TimeOnly.MinValue);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PropertyType.TimeOnly:
|
||||||
|
_time = ((TimeOnly)value).ToTimeSpan();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public required PropertyConfig Config { get; set; }
|
public required PropertyConfig Config { get; set; }
|
||||||
@@ -103,4 +173,59 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public Variant Variant { get; set; }
|
public Variant Variant { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<object?> ValueChanged { get; set; }
|
||||||
|
|
||||||
|
private DateTime _date;
|
||||||
|
private TimeSpan _time;
|
||||||
|
|
||||||
|
private InputType _passwordInputType = InputType.Password;
|
||||||
|
private string _passwordIcon = Icons.Material.Filled.VisibilityOff;
|
||||||
|
|
||||||
|
private async Task OnValueChanged(object? value) {
|
||||||
|
if (value is DateTime dt) {
|
||||||
|
_date = dt;
|
||||||
|
switch ((PropertyType)((byte)Config.PropertyType & 0x0F)) {
|
||||||
|
case PropertyType.DateOnly:
|
||||||
|
value = DateOnly.FromDateTime(dt);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PropertyType.DateTime:
|
||||||
|
value = DateOnly.FromDateTime(dt).ToDateTime(TimeOnly.FromTimeSpan(_time));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is TimeSpan ts) {
|
||||||
|
_time = ts;
|
||||||
|
switch ((PropertyType)((byte)Config.PropertyType & 0x0F)) {
|
||||||
|
case PropertyType.DateOnly:
|
||||||
|
value = TimeOnly.FromTimeSpan(ts);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PropertyType.DateTime:
|
||||||
|
value = DateOnly.FromDateTime(_date).ToDateTime(TimeOnly.FromTimeSpan(ts));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((PropertyType)((byte)Config.PropertyType & 0x0F) == PropertyType.Enum && (Config.PropertyType & PropertyType.List) != 0) {
|
||||||
|
var list = value as IReadOnlyCollection<object?>;
|
||||||
|
var newValue = Activator.CreateInstance(Config.Type) as IList;
|
||||||
|
foreach (var entry in list!) {
|
||||||
|
newValue!.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
value = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ValueChanged.HasDelegate)
|
||||||
|
await ValueChanged.InvokeAsync(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPasswordIconClick() {
|
||||||
|
_passwordIcon = _passwordInputType == InputType.Password ? Icons.Material.Filled.VisibilityOff : Icons.Material.Filled.Visibility;
|
||||||
|
_passwordInputType = _passwordInputType == InputType.Password ? InputType.Text : InputType.Password;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user