Implement Commanding #6
40
frontend/src/components/CommandInput.vue
Normal file
40
frontend/src/components/CommandInput.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import {
|
||||
type AnyTypeId,
|
||||
type DynamicDataType,
|
||||
isBooleanType,
|
||||
isNumericType,
|
||||
} from '@/composables/dynamic.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
type: AnyTypeId;
|
||||
}>();
|
||||
|
||||
const model = defineModel<DynamicDataType>();
|
||||
|
||||
const is_numeric = computed(() => {
|
||||
return isNumericType(props.type);
|
||||
});
|
||||
|
||||
const is_boolean = computed(() => {
|
||||
return isBooleanType(props.type);
|
||||
});
|
||||
|
||||
// Initialize the parameter to some value:
|
||||
onMounted(() => {
|
||||
if (is_numeric.value) {
|
||||
model.value = 0.0;
|
||||
} else if (is_boolean.value) {
|
||||
model.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-if="is_numeric" type="number" v-model="model" />
|
||||
<input v-else-if="is_boolean" type="checkbox" v-model="model" />
|
||||
<span v-else>UNKNOWN INPUT</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,36 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { CommandParameterDefinition } from '@/composables/command.ts';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { type DynamicDataType } from '@/composables/dynamic.ts';
|
||||
import CommandInput from '@/components/CommandInput.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
parameter: CommandParameterDefinition;
|
||||
}>();
|
||||
|
||||
const model = defineModel<any>(); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const is_numeric = computed(() => {
|
||||
return ['Float32', 'Float64'].some((x) => x == props.parameter.data_type);
|
||||
});
|
||||
|
||||
const is_boolean = computed(() => {
|
||||
return 'Boolean' == props.parameter.data_type;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (is_numeric.value) {
|
||||
model.value = 0.0;
|
||||
} else if (is_boolean.value) {
|
||||
model.value = false;
|
||||
}
|
||||
});
|
||||
const model = defineModel<DynamicDataType>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row">
|
||||
<label> {{ parameter.name }} </label>
|
||||
<input v-if="is_numeric" type="number" v-model="model" />
|
||||
<input v-else-if="is_boolean" type="checkbox" v-model="model" />
|
||||
<span v-else>UNKNOWN INPUT</span>
|
||||
<CommandInput
|
||||
:type="parameter.data_type"
|
||||
v-model="model"
|
||||
></CommandInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
29
frontend/src/components/CommandParameterDataConfigurator.vue
Normal file
29
frontend/src/components/CommandParameterDataConfigurator.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { type CommandParameterData } from '@/composables/dynamic.ts';
|
||||
import { type CommandParameterDefinition } from '@/composables/command.ts';
|
||||
import CommandInput from '@/components/CommandInput.vue';
|
||||
|
||||
defineProps<{
|
||||
param: CommandParameterDefinition;
|
||||
}>();
|
||||
|
||||
const model = defineModel<CommandParameterData>({
|
||||
required: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="model.type == 'constant'" class="row">
|
||||
<label>Value:</label>
|
||||
<CommandInput
|
||||
:type="param.data_type"
|
||||
v-model="model.value"
|
||||
></CommandInput>
|
||||
</div>
|
||||
<div v-if="model.type == 'input'" class="row">
|
||||
<label>ID:</label>
|
||||
<input type="text" v-model="model.id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
99
frontend/src/components/CommandParameterListConfigurator.vue
Normal file
99
frontend/src/components/CommandParameterListConfigurator.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type CommandParameterData,
|
||||
type DynamicDataType,
|
||||
isBooleanType,
|
||||
isNumericType,
|
||||
} from '@/composables/dynamic.ts';
|
||||
import { useCommand } from '@/composables/command.ts';
|
||||
import { watch } from 'vue';
|
||||
import FlexDivider from '@/components/FlexDivider.vue';
|
||||
import CommandParameterDataConfigurator from '@/components/CommandParameterDataConfigurator.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
command_name: string;
|
||||
}>();
|
||||
|
||||
const model = defineModel<{ [key: string]: CommandParameterData }>({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const { data: command_info } = useCommand(props.command_name);
|
||||
|
||||
watch([command_info], ([cmd_info]) => {
|
||||
if (cmd_info == null) {
|
||||
return;
|
||||
}
|
||||
const model_value = model.value;
|
||||
for (const key in model_value) {
|
||||
const is_valid_param = cmd_info.parameters.some(
|
||||
(param) => param.name == key,
|
||||
);
|
||||
if (!is_valid_param) {
|
||||
delete model_value[key];
|
||||
}
|
||||
}
|
||||
for (const param of cmd_info.parameters) {
|
||||
let model_param_value: CommandParameterData | undefined =
|
||||
model_value[param.name];
|
||||
if (model_param_value) {
|
||||
switch (model_param_value.type) {
|
||||
case 'constant':
|
||||
if (
|
||||
typeof model_param_value.value == 'number' &&
|
||||
!isNumericType(param.data_type)
|
||||
) {
|
||||
model_param_value = undefined;
|
||||
} else if (
|
||||
typeof model_param_value.value == 'boolean' &&
|
||||
!isBooleanType(param.data_type)
|
||||
) {
|
||||
model_param_value = undefined;
|
||||
}
|
||||
break;
|
||||
case 'input':
|
||||
// Nothing to do
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!model_param_value) {
|
||||
let default_value: DynamicDataType = 0;
|
||||
if (isNumericType(param.data_type)) {
|
||||
default_value = 0;
|
||||
} else if (isBooleanType(param.data_type)) {
|
||||
default_value = false;
|
||||
}
|
||||
model_param_value = {
|
||||
type: 'constant',
|
||||
value: default_value,
|
||||
};
|
||||
}
|
||||
model_value[param.name] = model_param_value;
|
||||
}
|
||||
model.value = model_value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="command_info">
|
||||
<template v-for="param in command_info.parameters" :key="param.name">
|
||||
<FlexDivider></FlexDivider>
|
||||
<div class="row">
|
||||
<label>{{ param.name }}</label>
|
||||
<select v-model="model[param.name].type">
|
||||
<option value="constant">Constant</option>
|
||||
<option value="input">Input</option>
|
||||
</select>
|
||||
</div>
|
||||
<CommandParameterDataConfigurator
|
||||
:param="param"
|
||||
v-model="model[param.name]"
|
||||
></CommandParameterDataConfigurator>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span> Loading... </span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -3,12 +3,13 @@ import type { CommandDefinition } from '@/composables/command.ts';
|
||||
import { ref } from 'vue';
|
||||
import CommandParameter from '@/components/CommandParameter.vue';
|
||||
import FlexDivider from '@/components/FlexDivider.vue';
|
||||
import type { DynamicDataType } from '@/composables/dynamic.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
command: CommandDefinition | null;
|
||||
}>();
|
||||
|
||||
const parameters = ref<any>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const parameters = ref<{ [key: string]: DynamicDataType }>({});
|
||||
const busy = ref(false);
|
||||
const result = ref('');
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import {
|
||||
AnyTypes,
|
||||
type CommandParameterData,
|
||||
type DynamicDataType,
|
||||
type OptionalDynamicComponentData,
|
||||
} from '@/composables/dynamic.ts';
|
||||
import { computed, defineAsyncComponent, inject, type Ref, ref } from 'vue';
|
||||
import CommandParameterListConfigurator from '@/components/CommandParameterListConfigurator.vue';
|
||||
|
||||
const TelemetryValue = defineAsyncComponent(
|
||||
() => import('@/components/TelemetryValue.vue'),
|
||||
@@ -8,6 +14,9 @@ const TelemetryValue = defineAsyncComponent(
|
||||
const GridLayout = defineAsyncComponent(
|
||||
() => import('@/components/layout/GridLayout.vue'),
|
||||
);
|
||||
const CommandInput = defineAsyncComponent(
|
||||
() => import('@/components/CommandInput.vue'),
|
||||
);
|
||||
|
||||
const model = defineModel<OptionalDynamicComponentData>('data', {
|
||||
required: true,
|
||||
@@ -19,13 +28,26 @@ const props = defineProps<{
|
||||
editable: boolean;
|
||||
}>();
|
||||
|
||||
const busy = ref(false);
|
||||
|
||||
// Provide a fallback option
|
||||
const inputs = inject<Ref<{ [id: string]: DynamicDataType }>>(
|
||||
'inputs',
|
||||
ref({}),
|
||||
);
|
||||
|
||||
const thisSymbol = Symbol();
|
||||
|
||||
const isSelected = computed(() => {
|
||||
return selection.value == thisSymbol && props.editable;
|
||||
});
|
||||
|
||||
function selectThis() {
|
||||
function selectThis(e: Event) {
|
||||
if (props.editable) {
|
||||
// Only do this when we are editable
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
selection.value = thisSymbol;
|
||||
}
|
||||
|
||||
@@ -100,6 +122,54 @@ function deleteColumn() {
|
||||
model.value = grid;
|
||||
}
|
||||
}
|
||||
|
||||
function makeInput() {
|
||||
model.value = {
|
||||
type: 'input',
|
||||
id: [...Array(32)]
|
||||
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||
.join(''),
|
||||
data_type: 'Float32',
|
||||
};
|
||||
}
|
||||
|
||||
function makeCommandButton() {
|
||||
model.value = {
|
||||
type: 'command_button',
|
||||
text: 'Button Text',
|
||||
command_name: '',
|
||||
parameters: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function sendCommand(command: {
|
||||
command_name: string;
|
||||
parameters: { [key: string]: CommandParameterData };
|
||||
}) {
|
||||
busy.value = true;
|
||||
|
||||
const params: { [key: string]: DynamicDataType } = {};
|
||||
for (const param_name in command.parameters) {
|
||||
const parameter = command.parameters[param_name];
|
||||
switch (parameter.type) {
|
||||
case 'constant':
|
||||
params[param_name] = parameter.value;
|
||||
break;
|
||||
case 'input':
|
||||
params[param_name] = inputs.value[parameter.id];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await fetch(`/api/cmd/${command.command_name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
busy.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -123,20 +193,30 @@ function deleteColumn() {
|
||||
<button v-if="model.type != 'grid'" @click.stop.prevent="makeGrid">
|
||||
Make Grid
|
||||
</button>
|
||||
<button
|
||||
v-if="model.type != 'input'"
|
||||
@click.stop.prevent="makeInput"
|
||||
>
|
||||
Make Input
|
||||
</button>
|
||||
<button
|
||||
v-if="model.type != 'command_button'"
|
||||
@click.stop.prevent="makeCommandButton"
|
||||
>
|
||||
Make Command Button
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
<template v-if="model.type == 'none'">
|
||||
<!-- Intentionally Left Empty -->
|
||||
<span
|
||||
v-if="editable"
|
||||
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
|
||||
@click.stop.prevent="selectThis"
|
||||
@click="selectThis"
|
||||
></span>
|
||||
</template>
|
||||
<template v-else-if="model.type == 'text'">
|
||||
<span
|
||||
:class="`${model.justify_right ? 'justify-right' : ''} ${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
|
||||
@click.stop.prevent="selectThis"
|
||||
@click="selectThis"
|
||||
>
|
||||
{{ model.text }}
|
||||
</span>
|
||||
@@ -155,7 +235,7 @@ function deleteColumn() {
|
||||
<span
|
||||
v-if="editable"
|
||||
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
|
||||
@click.stop.prevent="selectThis"
|
||||
@click="selectThis"
|
||||
>
|
||||
{{ '{' }} {{ model.data }} {{ '}' }}
|
||||
</span>
|
||||
@@ -163,7 +243,7 @@ function deleteColumn() {
|
||||
v-else
|
||||
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
|
||||
:data="model.data"
|
||||
@click.stop.prevent="selectThis"
|
||||
@click="selectThis"
|
||||
></TelemetryValue>
|
||||
<Teleport v-if="isSelected" to="#inspector">
|
||||
<label>Telemetry Item: </label>
|
||||
@@ -175,7 +255,7 @@ function deleteColumn() {
|
||||
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
|
||||
:cols="model.columns"
|
||||
:equal_col_width="model.equal_width"
|
||||
@click.stop.prevent="selectThis"
|
||||
@click="selectThis"
|
||||
>
|
||||
<template v-for="x in model.cells.length" :key="x">
|
||||
<template v-for="y in model.columns" :key="y">
|
||||
@@ -210,6 +290,60 @@ function deleteColumn() {
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
<template v-else-if="model.type == 'input'">
|
||||
<span
|
||||
v-if="editable"
|
||||
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
|
||||
@click="selectThis"
|
||||
>
|
||||
{{ '[' }} {{ model.id }} {{ ']' }}
|
||||
</span>
|
||||
<CommandInput
|
||||
v-else
|
||||
:type="model.data_type"
|
||||
v-model="inputs[model.id]"
|
||||
></CommandInput>
|
||||
<Teleport v-if="isSelected" to="#inspector">
|
||||
<div class="row">
|
||||
<label>Input ID: </label>
|
||||
<input v-model="model.id" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Data Type: </label>
|
||||
<select v-model="model.data_type">
|
||||
<option v-for="type in AnyTypes" :key="type" :value="type">
|
||||
{{ type }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
<template v-else-if="model.type == 'command_button'">
|
||||
<button
|
||||
:disabled="busy"
|
||||
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
|
||||
@click.stop.prevent="
|
||||
(e) => (editable ? selectThis(e) : sendCommand(model as any))
|
||||
"
|
||||
>
|
||||
{{ model.text }}
|
||||
</button>
|
||||
<Teleport v-if="isSelected" to="#inspector">
|
||||
<div class="row">
|
||||
<label>Button Text: </label>
|
||||
<input v-model="model.text" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Command: </label>
|
||||
<input v-model="model.command_name" />
|
||||
</div>
|
||||
<CommandParameterListConfigurator
|
||||
:key="model.command_name"
|
||||
:command_name="model.command_name"
|
||||
v-model="model.parameters"
|
||||
></CommandParameterListConfigurator>
|
||||
</Teleport>
|
||||
</template>
|
||||
<template v-else> ERROR: Unknown data: {{ model }} </template>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ import { AXIS_DATA, type AxisData } from '@/graph/axis';
|
||||
import ValueLabel from '@/components/ValueLabel.vue';
|
||||
import { type Point, PointLine } from '@/graph/line';
|
||||
import TooltipDialog from '@/components/TooltipDialog.vue';
|
||||
import {
|
||||
type DynamicDataType,
|
||||
isBooleanType,
|
||||
isNumericType,
|
||||
} from '@/composables/dynamic.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
data: string;
|
||||
@@ -98,13 +103,9 @@ watch([value], ([val]) => {
|
||||
if (val_t >= min_x) {
|
||||
const raw_item_val = val.value[telemetry_data.value!.data_type];
|
||||
let item_val = 0;
|
||||
if (
|
||||
['Float32', 'Float64'].some(
|
||||
(e) => e == telemetry_data.value!.data_type,
|
||||
)
|
||||
) {
|
||||
if (isNumericType(telemetry_data.value!.data_type)) {
|
||||
item_val = raw_item_val as number;
|
||||
} else if (telemetry_data.value!.data_type == 'Boolean') {
|
||||
} else if (isBooleanType(telemetry_data.value!.data_type)) {
|
||||
item_val = (raw_item_val as boolean) ? 1 : 0;
|
||||
}
|
||||
const new_item = {
|
||||
@@ -140,15 +141,13 @@ watch(
|
||||
const response = (await res.json()) as TelemetryDataItem[];
|
||||
for (const data_item of response) {
|
||||
const val_t = Date.parse(data_item.timestamp);
|
||||
const raw_item_val = data_item.value[type];
|
||||
const raw_item_val = data_item.value[
|
||||
type
|
||||
] as DynamicDataType;
|
||||
let item_val = 0;
|
||||
if (
|
||||
['Float32', 'Float64'].some(
|
||||
(e) => e == telemetry_data.value!.data_type,
|
||||
)
|
||||
) {
|
||||
if (isNumericType(type)) {
|
||||
item_val = raw_item_val as number;
|
||||
} else if (type == 'Boolean') {
|
||||
} else if (isBooleanType(type)) {
|
||||
item_val = (raw_item_val as boolean) ? 1 : 0;
|
||||
}
|
||||
const new_item = {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ref, toValue, watchEffect } from 'vue';
|
||||
import { type MaybeRefOrGetter } from 'vue';
|
||||
import type { AnyTypeId } from '@/composables/dynamic.ts';
|
||||
|
||||
export interface CommandParameterDefinition {
|
||||
name: string;
|
||||
data_type: string;
|
||||
data_type: AnyTypeId;
|
||||
}
|
||||
|
||||
export interface CommandDefinition {
|
||||
@@ -30,7 +31,7 @@ export function useAllCommands() {
|
||||
return { data, error };
|
||||
}
|
||||
|
||||
export function ueCommand(name: MaybeRefOrGetter<string>) {
|
||||
export function useCommand(name: MaybeRefOrGetter<string>) {
|
||||
const data = ref<CommandDefinition | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const error = ref<any | null>(null);
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
export const NumericTypes = ['Float32', 'Float64'] as const;
|
||||
export type NumericTypeId = (typeof NumericTypes)[number];
|
||||
export const BooleanTypes = ['Boolean'] as const;
|
||||
export type BooleanTypeId = (typeof BooleanTypes)[number];
|
||||
export const AnyTypes = [...NumericTypes, ...BooleanTypes] as const;
|
||||
export type AnyTypeId = (typeof AnyTypes)[number];
|
||||
|
||||
export function isNumericType(type: AnyTypeId): type is NumericTypeId {
|
||||
return NumericTypes.some((it) => it == type);
|
||||
}
|
||||
export function isBooleanType(type: AnyTypeId): type is BooleanTypeId {
|
||||
return BooleanTypes.some((it) => it == type);
|
||||
}
|
||||
|
||||
export type DynamicDataType = number | boolean;
|
||||
|
||||
export type CommandParameterData =
|
||||
| {
|
||||
type: 'constant';
|
||||
value: DynamicDataType;
|
||||
}
|
||||
| {
|
||||
type: 'input';
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type DynamicComponentData =
|
||||
| { type: 'text'; text: string; justify_right: boolean }
|
||||
| { type: 'telemetry'; data: string }
|
||||
@@ -6,6 +32,17 @@ export type DynamicComponentData =
|
||||
columns: number;
|
||||
equal_width: boolean;
|
||||
cells: OptionalDynamicComponentData[][];
|
||||
}
|
||||
| {
|
||||
type: 'input';
|
||||
id: string;
|
||||
data_type: AnyTypeId;
|
||||
}
|
||||
| {
|
||||
type: 'command_button';
|
||||
text: string;
|
||||
command_name: string;
|
||||
parameters: { [key: string]: CommandParameterData };
|
||||
};
|
||||
|
||||
export type OptionalDynamicComponentData =
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ref, toValue, watchEffect } from 'vue';
|
||||
import { type MaybeRefOrGetter } from 'vue';
|
||||
import type { AnyTypeId } from '@/composables/dynamic.ts';
|
||||
|
||||
export interface TelemetryDefinition {
|
||||
uuid: string;
|
||||
name: string;
|
||||
data_type: string;
|
||||
data_type: AnyTypeId;
|
||||
}
|
||||
|
||||
export function useAllTelemetry() {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import DynamicComponent from '@/components/DynamicComponent.vue';
|
||||
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
|
||||
import { computed, ref, watchEffect } from 'vue';
|
||||
import type {
|
||||
DynamicDataType,
|
||||
OptionalDynamicComponentData,
|
||||
} from '@/composables/dynamic.ts';
|
||||
import { computed, provide, ref, watchEffect } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -12,6 +15,10 @@ const panel = ref<OptionalDynamicComponentData>({
|
||||
type: 'none',
|
||||
});
|
||||
|
||||
const inputs = ref<{ [id: string]: DynamicDataType }>({});
|
||||
|
||||
provide('inputs', inputs);
|
||||
|
||||
watchEffect(async () => {
|
||||
const panel_data = await fetch(`/api/panel/${id.value}`);
|
||||
const panel_json_value = await panel_data.json();
|
||||
|
||||
Reference in New Issue
Block a user