added side panels
This commit is contained in:
@@ -1,54 +1,93 @@
|
||||
<div class="container">
|
||||
<mat-sidenav-container
|
||||
fullscreen
|
||||
[hasBackdrop]="mobileQuery.matches">
|
||||
<mat-sidenav #lnav
|
||||
mode="over"
|
||||
class="sidenav">
|
||||
<div class="content">
|
||||
<mat-accordion >
|
||||
<mat-expansion-panel *ngFor="let location of this.dataservice.serverMessages">
|
||||
<mat-sidenav-container fullscreen> <!--[hasBackdrop]="mobileQuery.matches">-->
|
||||
|
||||
<mat-sidenav #lnav mode="side" class="sidenav" closed >
|
||||
<div class="content">
|
||||
<mat-accordion>
|
||||
<mat-expansion-panel *ngFor="let fields of this.sidebar | keyvalue">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>{{location.location}}</mat-panel-title>
|
||||
<mat-panel-title>{{fields.key}}</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<p *ngFor="let keys of location | keyvalue">{{keys.key}} {{keys.value}}</p>
|
||||
<mat-accordion>
|
||||
<mat-expansion-panel *ngFor="let location of fields.value"
|
||||
cdkDropList #locationList="cdkDropList" [cdkDropListConnectedTo]="[chartList]" (cdkDropListDropped)="drop($event)">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title cdkDrag>{{location.location}}</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<div class="item-list">
|
||||
<div class="item-box" *ngFor="let keys of location | keyvalue" cdkDrag>{{keys.key}}: {{keys.value}}</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
</mat-sidenav>
|
||||
|
||||
|
||||
<mat-sidenav #rnav mode="side" class="sidenav" position="end" closed >
|
||||
<div *ngFor="let route of nav">
|
||||
<a mat-button routerLink="{{route.path}}" routerLinkActive="active" (click)="toggleMobileNav(rnav)" >{{route.title}}</a>
|
||||
</div>
|
||||
<mat-menu #appMenu="matMenu">
|
||||
<ng-container *ngFor="let item of this.dataservice.groups; let i = index">
|
||||
<button mat-menu-item (click)="roleChange(i)"> {{ item }}</button>
|
||||
</ng-container>
|
||||
</mat-menu>
|
||||
<button mat-button [matMenuTriggerFor]="appMenu" >
|
||||
Role Change
|
||||
</button>
|
||||
<div></div>
|
||||
<button mat-flat-button (click)="this.signOut(rnav)" color="accent">Sign Out</button>
|
||||
</mat-sidenav>
|
||||
|
||||
<mat-sidenav-content class="sidenav-content">
|
||||
<mat-toolbar
|
||||
class="toolbar"
|
||||
[class.app-is-mobile]="mobileQuery.matches"
|
||||
color="primary">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="lnav.toggle()"
|
||||
*ngIf="mobileQuery.matches">
|
||||
|
||||
<mat-toolbar class="toolbar" [class.app-is-mobile]="mobileQuery.matches" color="primary">
|
||||
<button mat-icon-button (click)="lnav.toggle()" *ngIf="this.authService.loggedIn">
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
{{title}}
|
||||
<div *ngFor="let route of nav">
|
||||
<div class="flex-spacer"></div>
|
||||
<button mat-icon-button (click)="rnav.toggle()" *ngIf="this.authService.loggedIn">
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
<!-- <div *ngFor="let route of nav">
|
||||
<a mat-button routerLink="{{route.path}}" routerLinkActive="active" (click)="toggleMobileNav(lnav)">{{route.title}}</a>
|
||||
</div>
|
||||
<div class="fill-space"></div>
|
||||
<span whoami></span>
|
||||
<div class="flex-spacer"></div>
|
||||
<mat-menu #appMenu="matMenu">
|
||||
<ng-container *ngFor="let item of this.dataservice.groups; let i = index">
|
||||
<button mat-menu-item (click)="roleChange(i)"> {{ item }}</button>
|
||||
</ng-container>
|
||||
</mat-menu>
|
||||
<button mat-icon-button [matMenuTriggerFor]="appMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<span whoami></span> -->
|
||||
</mat-toolbar>
|
||||
|
||||
<mat-drawer-container class="sidenav-container">
|
||||
<mat-drawer
|
||||
mode="side"
|
||||
[opened]="!mobileQuery.matches">
|
||||
<!-- <mat-drawer mode="side" [opened]="!mobileQuery.matches">
|
||||
<div class="content">
|
||||
<mat-accordion >
|
||||
<mat-expansion-panel *ngFor="let location of this.dataservice.serverMessages">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>{{location.location}}</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<p *ngFor="let keys of location | keyvalue">{{keys.key}}: {{keys.value}}</p>
|
||||
</mat-expansion-panel>
|
||||
<mat-accordion>
|
||||
<mat-expansion-panel *ngFor="let fields of this.sidebar | keyvalue">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>{{fields.key}}</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<mat-accordion>
|
||||
<mat-expansion-panel *ngFor="let location of fields.value">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>{{location.location}}</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<p *ngFor="let keys of location | keyvalue">{{keys.key}}: {{keys.value}}</p>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
</mat-drawer>
|
||||
</mat-drawer> -->
|
||||
<mat-drawer-content>
|
||||
<div class="content">
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
@@ -77,4 +77,30 @@ a {
|
||||
|
||||
.active {
|
||||
color: mat-color($app-active);
|
||||
}
|
||||
}
|
||||
|
||||
.flex-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.item-list {
|
||||
border: solid 1px #ccc;
|
||||
min-height: 60px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.item-box {
|
||||
padding: 20px 10px;
|
||||
border-bottom: solid 1px #ccc;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
cursor: move;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Component, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
|
||||
import { Component, ChangeDetectorRef, EventEmitter, Output, OnInit } from '@angular/core';
|
||||
import { MediaMatcher } from '@angular/cdk/layout';
|
||||
import { MatSidenav } from '@angular/material/sidenav';
|
||||
import { DataService } from './services/data.service';
|
||||
import { DataService, AWSData } from './services/data.service';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { RangeValueAccessor } from '@angular/forms';
|
||||
import { CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
|
||||
|
||||
@Component({
|
||||
@@ -9,8 +13,9 @@ import { DataService } from './services/data.service';
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'Henry Pump SCADA';
|
||||
export class AppComponent implements OnInit {
|
||||
title = 'HP SCADA';
|
||||
sidebar: AWSData = [];
|
||||
mobileQuery: MediaQueryList;
|
||||
nav = [
|
||||
{
|
||||
@@ -27,19 +32,48 @@ export class AppComponent {
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
private mobileQueryListener: () => void;
|
||||
@Output() toggleSideNav = new EventEmitter();
|
||||
|
||||
constructor( changeDetectorRef: ChangeDetectorRef, media: MediaMatcher , public dataservice: DataService) {
|
||||
constructor( changeDetectorRef: ChangeDetectorRef, media: MediaMatcher , public dataservice: DataService,
|
||||
public authService: AuthService, private router: Router) {
|
||||
this.mobileQuery = media.matchMedia('(max-width: 600px)');
|
||||
this.mobileQueryListener = () => changeDetectorRef.detectChanges();
|
||||
this.mobileQuery.addListener(this.mobileQueryListener);
|
||||
}
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
this.dataservice.message.subscribe((data) => {
|
||||
data.forEach( element => {
|
||||
if (this.sidebar[element.field]) {
|
||||
this.sidebar[element.field].push(element);
|
||||
} else {
|
||||
this.sidebar[element.field] = [];
|
||||
this.sidebar[element.field].push(element);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
roleChange(index: number) {
|
||||
this.sidebar = [];
|
||||
this.dataservice.setRole(index);
|
||||
}
|
||||
toggleMobileNav(nav: MatSidenav) {
|
||||
if (this.mobileQuery.matches) {
|
||||
nav.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
signOut(nav: MatSidenav) {
|
||||
nav.toggle();
|
||||
this.sidebar = [];
|
||||
this.dataservice.serverMessages = [];
|
||||
this.authService.signOut()
|
||||
.then(() => this.router.navigate(['auth/signin']));
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<string[]>) {
|
||||
console.log(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Auth, { CognitoHostedUIIdentityProvider } from '@aws-amplify/auth';
|
||||
import { Hub, ICredentials } from '@aws-amplify/core';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
import { CognitoUser } from 'amazon-cognito-identity-js';
|
||||
import { DataService } from '../services/data.service';
|
||||
|
||||
export interface NewUser {
|
||||
email: string;
|
||||
@@ -26,13 +27,14 @@ export class AuthService {
|
||||
authState: Observable<CognitoUser|any> = this.authStateSubject.asObservable();
|
||||
|
||||
|
||||
constructor() {
|
||||
constructor(private dataService: DataService) {
|
||||
Hub.listen('auth', (data) => {
|
||||
const { channel, payload } = data;
|
||||
if (channel === 'auth') {
|
||||
this.authStateSubject.next(payload.event);
|
||||
}
|
||||
});
|
||||
Auth.currentAuthenticatedUser().then(() => this.loggedIn = true).catch(() => this.loggedIn = false);
|
||||
}
|
||||
|
||||
signUp(user: NewUser): Promise<CognitoUser|any> {
|
||||
@@ -53,6 +55,7 @@ export class AuthService {
|
||||
Auth.signIn(username, password)
|
||||
.then((user: CognitoUser|any) => {
|
||||
this.loggedIn = true;
|
||||
this.dataService.startUp();
|
||||
resolve(user);
|
||||
}).catch((error: any) => reject(error));
|
||||
});
|
||||
|
||||
@@ -1,19 +1,4 @@
|
||||
<div class ="viewer">
|
||||
<div class="container" fxLayout="row wrap" fxLayoutGap="30px">
|
||||
Total Flow Rate {{ totalFlowRate }}
|
||||
|
||||
<div class="flex-spacer"></div>
|
||||
|
||||
<mat-menu #appMenu="matMenu">
|
||||
<ng-container *ngFor="let item of groups; let i = index">
|
||||
<button mat-menu-item (click)="setRole(i)"> {{ item }}</button>
|
||||
</ng-container>
|
||||
</mat-menu>
|
||||
|
||||
<button mat-icon-button [matMenuTriggerFor]="appMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div id="charts"></div>
|
||||
<div id="charts" cdkDropList #chartList="cdkDropList" [cdkDropListConnectedTo]="[locationList]" (cdkDropListDropped)="drop($event)"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Chart } from 'chart.js';
|
||||
import { DataService, MyJSON } from '../services/data.service';
|
||||
import { DataService, AWSData } from '../services/data.service';
|
||||
import { CdkDropList } from '@angular/cdk/drag-drop';
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './dashboard.component.html',
|
||||
@@ -9,46 +10,111 @@ import { DataService, MyJSON } from '../services/data.service';
|
||||
|
||||
export class DashboardComponent implements OnInit {
|
||||
|
||||
constructor(private dataservice: DataService) { }
|
||||
constructor(private dataservice: DataService) { }
|
||||
|
||||
title = 'Bar Chart';
|
||||
charts = new Array<Chart>();
|
||||
messages: Array<MyJSON>;
|
||||
charttypes = ['bar', 'line', 'radar', 'doughnut', 'pie', 'polarArea', 'bubble', 'scatter'];
|
||||
ngOnInit() {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const chart = document.createElement('canvas');
|
||||
chart.id = 'chart-' + i.toString();
|
||||
chart.style.width = '100%';
|
||||
chart.style.height = '500px';
|
||||
document.getElementById('charts').appendChild(chart);
|
||||
this.charts.push(new Chart(document.getElementById('chart-' + i.toString()), {
|
||||
type: this.charttypes[i],
|
||||
charts: Array<Chart>;
|
||||
messages: Array<AWSData> = [];
|
||||
charttypes = ['bar', 'line'];
|
||||
userCharts = {
|
||||
'arn:aws:iam::860246592755:role/HPIoT_CrownQuest_User': {
|
||||
'chart-0': {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
labels: ['Horton 20 WS 9-8', 'Horton 20 WS 9-7', 'Horton 20 WS 10-20', 'Horton 20 WS 10-20'].sort((a, b) => a.localeCompare(b)),
|
||||
datasets: [{
|
||||
label: '',
|
||||
label: 'Horton',
|
||||
data: {
|
||||
'Horton 20 WS 9-7': ['volumeflow'],
|
||||
'Horton 20 WS 10-20': ['pressure', 'depth'],
|
||||
'Horton 20 WS 9-8': ['volumeflow']
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
'chart-1': {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['LimeQuest 5 WS 1-1', 'Wilkinson 34 WS 2-2', 'Horton 20 WS 10-20'].sort((a, b) => a.localeCompare(b)),
|
||||
datasets: [{
|
||||
label: 'Horton',
|
||||
data: {
|
||||
'LimeQuest 5 WS 1-1': ['depth'],
|
||||
'Horton 20 WS 10-20': ['pressure'],
|
||||
'Wilkinson 34 WS 2-2': ['current']
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
'arn:aws:iam::860246592755:role/HPIoT_QEP_User': {
|
||||
'chart-0': {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['Frequency', 'Pressure', 'Current', 'Down Hole'],
|
||||
datasets: [{
|
||||
label: 'POE 1',
|
||||
data: {
|
||||
'POE 1': ['frequency', 'pressure', 'current', 'down_hole_level']
|
||||
}}]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
ngOnInit() {
|
||||
this.dataservice.roleSubject.subscribe(() => {
|
||||
this.initCharts();
|
||||
});
|
||||
this.initCharts();
|
||||
this.dataservice.message.subscribe((data) => {
|
||||
this.messages = data;
|
||||
// console.log(this.messages);
|
||||
this.populate();
|
||||
});
|
||||
}
|
||||
getData(req) {
|
||||
const x = [];
|
||||
this.messages.forEach(location => {
|
||||
if (Object.keys(req).indexOf(location.location) > -1) {
|
||||
req[location.location].forEach((ele) => {
|
||||
x.push(location[ele]);
|
||||
});
|
||||
}
|
||||
});
|
||||
return x;
|
||||
}
|
||||
initCharts() {
|
||||
document.getElementById('charts').innerHTML = '';
|
||||
this.charts = new Array<Chart>();
|
||||
Object.keys(this.userCharts[this.dataservice.currentRole]).forEach( key => {
|
||||
const chart = document.createElement('canvas');
|
||||
chart.id = key;
|
||||
chart.style.width = '100%';
|
||||
chart.style.height = '25rem';
|
||||
document.getElementById('charts').appendChild(chart);
|
||||
this.charts.push(new Chart(document.getElementById(key), {
|
||||
type: this.userCharts[this.dataservice.currentRole][key].type,
|
||||
data: {
|
||||
labels: this.userCharts[this.dataservice.currentRole][key].data.labels,
|
||||
datasets: [{
|
||||
label: this.userCharts[this.dataservice.currentRole][key].data.datasets.label,
|
||||
data: []
|
||||
}]
|
||||
}
|
||||
}));
|
||||
}
|
||||
this.dataservice.message.subscribe((data) => {
|
||||
this.messages = data;
|
||||
this.populate();
|
||||
});
|
||||
}
|
||||
|
||||
populate() {
|
||||
this.charts.forEach(element => {
|
||||
element.data.labels = this.messages.map((d) => d.location);
|
||||
element.data.datasets.forEach( dataset => {
|
||||
dataset.label = 'Volume Flow';
|
||||
dataset.data = this.messages.map( d => d.volumeflow);
|
||||
element.type = this.userCharts[this.dataservice.currentRole][element.canvas.id].type;
|
||||
element.data.labels = this.userCharts[this.dataservice.currentRole][element.canvas.id].data.labels;
|
||||
element.data.datasets.forEach( (dataset, ind) => {
|
||||
dataset.label = this.userCharts[this.dataservice.currentRole][element.canvas.id].data.datasets[ind].label;
|
||||
dataset.data = this.getData(this.userCharts[this.dataservice.currentRole][element.canvas.id].data.datasets[ind].data);
|
||||
dataset.backgroundColor = dataset.data.map((item, index) => 'rgba(' +
|
||||
index * (255 / this.messages.length) + ',' +
|
||||
index * (255 / dataset.data.length) + ',' +
|
||||
0 + ',' +
|
||||
(this.messages.length - index) * (255 / this.messages.length) + ',' + '0.8)');
|
||||
(dataset.data.length - index) * (255 / dataset.data.length) + ',' + '0.8)');
|
||||
});
|
||||
element.update();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import Auth from '@aws-amplify/auth';
|
||||
import { WebSocketSubject } from 'rxjs/webSocket';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
export interface MyJSON {
|
||||
import { Observable, Subject, BehaviorSubject } from 'rxjs';
|
||||
import { PassThrough } from 'stream';
|
||||
export interface AWSData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -12,30 +13,57 @@ export interface MyJSON {
|
||||
|
||||
|
||||
export class DataService {
|
||||
public serverMessages: Array<MyJSON> = [];
|
||||
public message = new Subject<MyJSON[]>();
|
||||
public serverMessages: Array<AWSData> = [];
|
||||
public message = new BehaviorSubject<AWSData[]>(this.serverMessages);
|
||||
private socket$: WebSocketSubject<any>;
|
||||
totalFlowRate = 0;
|
||||
private roles: string[];
|
||||
private groups: string[];
|
||||
private currentRole: string;
|
||||
public currentRole: string;
|
||||
public roleSubject = new Subject<string>();
|
||||
private token: string;
|
||||
|
||||
constructor() {
|
||||
Auth.currentAuthenticatedUser().then(data => {
|
||||
// console.log(data);
|
||||
this.roles = data.signInUserSession.idToken.payload['cognito:roles'];
|
||||
this.groups = data.signInUserSession.idToken.payload['cognito:groups'];
|
||||
this.groups.forEach( (element, index, array) => {
|
||||
array[index] = element.replace(/_/g, ' ');
|
||||
});
|
||||
this.currentRole = data.signInUserSession.idToken.payload['cognito:roles'][0];
|
||||
this.token = data.signInUserSession.accessToken.jwtToken;
|
||||
this.message.subscribe({
|
||||
next: d => d
|
||||
});
|
||||
this.connect();
|
||||
}); }
|
||||
// console.log(data);
|
||||
this.roles = data.signInUserSession.idToken.payload['cognito:roles'];
|
||||
this.groups = data.signInUserSession.idToken.payload['cognito:groups'];
|
||||
this.groups.forEach( (element, index, array) => {
|
||||
array[index] = element.replace(/_/g, ' ');
|
||||
});
|
||||
this.currentRole = data.signInUserSession.idToken.payload['cognito:roles'][0];
|
||||
this.token = data.signInUserSession.accessToken.jwtToken;
|
||||
this.message.subscribe({
|
||||
next: d => d
|
||||
});
|
||||
this.roleSubject.subscribe({
|
||||
next: r => r
|
||||
});
|
||||
this.connect();
|
||||
}).catch((err) =>
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
startUp() {
|
||||
Auth.currentAuthenticatedUser().then(data => {
|
||||
// console.log(data);
|
||||
this.roles = data.signInUserSession.idToken.payload['cognito:roles'];
|
||||
this.groups = data.signInUserSession.idToken.payload['cognito:groups'];
|
||||
this.groups.forEach( (element, index, array) => {
|
||||
array[index] = element.replace(/_/g, ' ');
|
||||
});
|
||||
this.currentRole = data.signInUserSession.idToken.payload['cognito:roles'][0];
|
||||
this.token = data.signInUserSession.accessToken.jwtToken;
|
||||
this.message.subscribe({
|
||||
next: d => d
|
||||
});
|
||||
this.roleSubject.subscribe({
|
||||
next: r => r
|
||||
});
|
||||
this.connect();
|
||||
}).catch((error) => console.log(error));
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.connectWS(this.token, this.currentRole);
|
||||
@@ -68,7 +96,7 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
updateList(obj: MyJSON) {
|
||||
updateList(obj: AWSData) {
|
||||
// console.log(this.serverMessages);
|
||||
// console.log(obj);
|
||||
const index = this.serverMessages.findIndex((e) => e.location === obj.location);
|
||||
@@ -85,6 +113,7 @@ export class DataService {
|
||||
|
||||
setRole(index: number) {
|
||||
this.currentRole = this.roles[index];
|
||||
this.roleSubject.next(this.currentRole);
|
||||
this.socket$.complete();
|
||||
this.serverMessages = [];
|
||||
this.connect();
|
||||
|
||||
Reference in New Issue
Block a user