Compare commits
61 Commits
0987affccc
...
master
Author | SHA1 | Date | |
---|---|---|---|
9338e7e624 | |||
daa500111c | |||
b0f9a2dea8 | |||
931046cbb3 | |||
01c62bc143 | |||
f4511157a5 | |||
f2c5178e82 | |||
7048a7c46c | |||
0a511f1424 | |||
b8a92534d9 | |||
3e9a9f9dc5 | |||
70e0e9bf71 | |||
b465f0a474 | |||
fcf1e9ac03 | |||
1e6690ff4b | |||
5489eb4df6 | |||
e053529d49 | |||
d69fc68ec1 | |||
d011571164 | |||
055885837c | |||
d44ec50a6a | |||
ea19375ad2 | |||
298d351e5d | |||
b1bac758e3 | |||
6e5efab5ec | |||
56deb3384c | |||
9de4424736 | |||
d19c5445d6 | |||
c8bfeed396 | |||
1acda7978e | |||
2f2215b041 | |||
d0556dce9c | |||
9201f9b6c5 | |||
74b282ccfd | |||
2f88840ef6 | |||
e949b6df08 | |||
e6f681219c | |||
a19a7a0217 | |||
6ee99466f8 | |||
d1eae32e4c | |||
5a94aa760b | |||
59bed7c28b | |||
45d6eaeb73 | |||
c62b9aaaad | |||
9cda9a5738 | |||
c5cdf84f3b | |||
04a50f6db0 | |||
7a7fb832a0 | |||
fee2c4e12f | |||
07db9000d8 | |||
2692294b4b | |||
d595c3500e | |||
11dfde9a03 | |||
96441946b6 | |||
08c146a9e9 | |||
740b76b6f8 | |||
fbd3c2226c | |||
6de2e1fbb2 | |||
567d12227f | |||
275069697f | |||
0afa2138b4 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -41,4 +41,6 @@ testem.log
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
src/environments/*
|
src/environments/
|
||||||
|
src/index.*.html
|
||||||
|
*.code-workspace
|
@ -1,4 +1,4 @@
|
|||||||
# HermesWebAngular
|
# Tom-to-Speech
|
||||||
|
|
||||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.5.
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.5.
|
||||||
|
|
||||||
|
18
angular.json
18
angular.json
@ -47,8 +47,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "3MB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "5MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
@ -56,6 +56,15 @@
|
|||||||
"maximumError": "4kB"
|
"maximumError": "4kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"optimization": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"namedChunks": false,
|
||||||
|
"aot": true,
|
||||||
|
"extractLicenses": true,
|
||||||
|
"index": {
|
||||||
|
"input": "src/index.prod.html",
|
||||||
|
"output": "index.html"
|
||||||
|
},
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
@ -82,7 +91,10 @@
|
|||||||
"buildTarget": "hermes-web-angular:build:development"
|
"buildTarget": "hermes-web-angular:build:development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development",
|
||||||
|
"options": {
|
||||||
|
"allowedHosts": ["beta.tomtospeech.com"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
8591
package-lock.json
generated
8591
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@ -2,40 +2,40 @@
|
|||||||
"name": "hermes-web-angular",
|
"name": "hermes-web-angular",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"start": "ng serve -c development --host 0.0.0.0 --watch false",
|
||||||
"start": "ng serve",
|
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng serve -c development --host 0.0.0.0 --disable-host-check",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"serve:ssr:hermes-web-angular": "node dist/hermes-web-angular/server/server.mjs"
|
"serve:ssr:hermes-web-angular": "node dist/hermes-web-angular/server/server.mjs"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^18.0.0",
|
"@angular/animations": "^19.2.4",
|
||||||
"@angular/cdk": "^18.2.8",
|
"@angular/cdk": "^19.2.7",
|
||||||
"@angular/common": "^18.0.0",
|
"@angular/common": "^19.2.4",
|
||||||
"@angular/compiler": "^18.0.0",
|
"@angular/compiler": "^19.2.4",
|
||||||
"@angular/core": "^18.0.0",
|
"@angular/core": "^19.2.4",
|
||||||
"@angular/forms": "^18.0.0",
|
"@angular/forms": "^19.2.4",
|
||||||
"@angular/material": "^18.2.8",
|
"@angular/material": "^19.2.7",
|
||||||
"@angular/platform-browser": "^18.0.0",
|
"@angular/platform-browser": "^19.2.4",
|
||||||
"@angular/platform-browser-dynamic": "^18.0.0",
|
"@angular/platform-browser-dynamic": "^19.2.4",
|
||||||
"@angular/platform-server": "^18.0.0",
|
"@angular/platform-server": "^19.2.4",
|
||||||
"@angular/router": "^18.0.0",
|
"@angular/router": "^19.2.4",
|
||||||
"@angular/ssr": "^18.0.5",
|
"@angular/ssr": "^19.2.5",
|
||||||
"angular-oauth2-oidc": "^17.0.2",
|
"angular-oauth2-oidc": "^17.0.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"moment": "^2.30.1",
|
||||||
"ngx-socket-io": "^4.7.0",
|
"ngx-socket-io": "^4.7.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"rxjs-websockets": "^9.0.0",
|
"rxjs-websockets": "^9.0.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"uuidv4": "^6.2.13",
|
"uuidv4": "^6.2.13",
|
||||||
"zone.js": "~0.14.3"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^18.0.5",
|
"@angular-devkit/build-angular": "^19.2.5",
|
||||||
"@angular/cli": "^18.0.5",
|
"@angular/cli": "^19.2.5",
|
||||||
"@angular/compiler-cli": "^18.0.0",
|
"@angular/compiler-cli": "^19.2.4",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"@types/node": "^18.18.0",
|
"@types/node": "^18.18.0",
|
||||||
@ -45,6 +45,6 @@
|
|||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "~5.4.2"
|
"typescript": "~5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { APP_BASE_HREF } from '@angular/common';
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
import { CommonEngine } from '@angular/ssr';
|
import { CommonEngine } from '@angular/ssr/node';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Redeemable Action</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="text"
|
||||||
|
placeholder="Pick a Redeemable Action"
|
||||||
|
aria-label="redeemable action"
|
||||||
|
[formControl]="formControl"
|
||||||
|
[matAutocomplete]="auto"
|
||||||
|
(blur)="blur()"
|
||||||
|
(input)="input()">
|
||||||
|
<mat-autocomplete #auto="matAutocomplete"
|
||||||
|
[displayWith]="displayFn"
|
||||||
|
(optionSelected)="select($event.option.value)">
|
||||||
|
@for (action of filteredActions; track action.name) {
|
||||||
|
<mat-option [value]="action">{{action.name}}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-autocomplete>
|
||||||
|
@if (!search && formControl.invalid && (formControl.dirty || formControl.touched)) {
|
||||||
|
@for (error of errorMessageKeys; track $index) {
|
||||||
|
@if (formControl.hasError(error)) {
|
||||||
|
<small class="error">{{errorMessages[error]}}</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ActionDropdownComponent } from './action-dropdown.component';
|
||||||
|
|
||||||
|
describe('ActionDropdownComponent', () => {
|
||||||
|
let component: ActionDropdownComponent;
|
||||||
|
let fixture: ComponentFixture<ActionDropdownComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ActionDropdownComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ActionDropdownComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
78
src/app/actions/action-dropdown/action-dropdown.component.ts
Normal file
78
src/app/actions/action-dropdown/action-dropdown.component.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import RedeemableAction from '../../shared/models/redeemable-action';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'action-dropdown',
|
||||||
|
imports: [MatAutocompleteModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule],
|
||||||
|
templateUrl: './action-dropdown.component.html',
|
||||||
|
styleUrl: './action-dropdown.component.scss'
|
||||||
|
})
|
||||||
|
export class ActionDropdownComponent implements OnInit {
|
||||||
|
@Input() formControl = new FormControl<RedeemableAction | string | undefined>(undefined);
|
||||||
|
@Input() errorMessages: { [errorKey: string]: string } = {}
|
||||||
|
|
||||||
|
@Input() search: boolean = false;
|
||||||
|
@Input() actions: RedeemableAction[] = [];
|
||||||
|
@Input() action: string | undefined;
|
||||||
|
@Output() readonly actionChange = new EventEmitter<string>();
|
||||||
|
|
||||||
|
errorMessageKeys: string[] = []
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.errorMessageKeys = Object.keys(this.errorMessages);
|
||||||
|
|
||||||
|
if (!this.action)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const action = this.actions.find(r => r.name == this.action);
|
||||||
|
this.formControl.setValue(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredActions() {
|
||||||
|
const value = this.formControl.value;
|
||||||
|
if (typeof value == 'string') {
|
||||||
|
return this.actions.filter(r => r.name.toLowerCase().includes(value.toLowerCase()));
|
||||||
|
}
|
||||||
|
return this.actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
select(event: RedeemableAction) {
|
||||||
|
this.actionChange.emit(event.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
input() {
|
||||||
|
if (this.search && typeof this.formControl.value == 'string') {
|
||||||
|
this.actionChange.emit(this.formControl.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blur() {
|
||||||
|
if (!this.search && typeof this.formControl.value == 'string') {
|
||||||
|
const name = this.formControl.value;
|
||||||
|
const nameLower = name.toLowerCase();
|
||||||
|
let newValue: RedeemableAction | undefined = undefined;
|
||||||
|
const insenstiveActions = this.filteredActions.filter(a => a.name.toLowerCase() == nameLower);
|
||||||
|
if (insenstiveActions.length > 1) {
|
||||||
|
const sensitiveAction = insenstiveActions.find(a => a.name == name);
|
||||||
|
newValue = sensitiveAction ?? undefined;
|
||||||
|
} else if (insenstiveActions.length == 1) {
|
||||||
|
newValue = insenstiveActions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValue && newValue.name != this.formControl.value) {
|
||||||
|
this.formControl.setValue(newValue);
|
||||||
|
this.actionChange.emit(newValue.name);
|
||||||
|
} else if (!newValue)
|
||||||
|
this.actionChange.emit(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayFn(value: any) {
|
||||||
|
return value?.name;
|
||||||
|
}
|
||||||
|
}
|
139
src/app/actions/action-item-edit/action-item-edit.component.html
Normal file
139
src/app/actions/action-item-edit/action-item-edit.component.html
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<content>
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title-group>
|
||||||
|
<mat-card-title>{{isNew ? "New Action" : previousName}}</mat-card-title>
|
||||||
|
<mat-card-subtitle>{{isNew ? 'Creating a new action' : 'Modifying an existing action'}}</mat-card-subtitle>
|
||||||
|
</mat-card-title-group>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<form class="grid"
|
||||||
|
[formGroup]="formGroup">
|
||||||
|
<div class="item">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Redeemable Action Name</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="text"
|
||||||
|
formControlName="name">
|
||||||
|
@if (isNew && formGroup.get('name')?.invalid && (formGroup.get('name')?.dirty ||
|
||||||
|
formGroup.get('name')?.touched)) {
|
||||||
|
@if (formGroup.get('name')?.hasError('required')) {
|
||||||
|
<small class="error">The name is required.</small>
|
||||||
|
}
|
||||||
|
@if (formGroup.get('name')?.hasError('itemExistsInArray')) {
|
||||||
|
<small class="error">The name is already in use.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Type</mat-label>
|
||||||
|
<mat-select matInput
|
||||||
|
formControlName="type"
|
||||||
|
(selectionChange)="action.type = $event.value">
|
||||||
|
@for (type of actionTypes; track $index) {
|
||||||
|
<mat-option value="{{type}}">{{type}}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (isNew && formGroup.get('type')?.invalid && (formGroup.get('type')?.dirty ||
|
||||||
|
formGroup.get('type')?.touched)) {
|
||||||
|
@if (formGroup.get('type')?.hasError('required')) {
|
||||||
|
<small class="error">The type is required.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (actionEntries.hasOwnProperty(action.type)) {
|
||||||
|
<section class="grid">
|
||||||
|
@for (field of actionEntries[action.type]; track $index) {
|
||||||
|
<div class="item">
|
||||||
|
@if (field.type == 'text') {
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{field.label}}</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="text"
|
||||||
|
placeholder="{{field.placeholder}}"
|
||||||
|
[formControl]="field.control"
|
||||||
|
[(ngModel)]="action.data[field.key]">
|
||||||
|
@if (field.control.invalid && (field.control.dirty || field.control.touched)) {
|
||||||
|
@if (field.control.hasError('required')) {
|
||||||
|
<small class="error">This field is required.</small>
|
||||||
|
}
|
||||||
|
@if (field.control.hasError('minlength')) {
|
||||||
|
<small class="error">The value needs to be longer.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
@else if (field.type == 'number') {
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{field.label}}</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="number"
|
||||||
|
[formControl]="field.control">
|
||||||
|
@if (field.control.invalid && (field.control.dirty || field.control.touched)) {
|
||||||
|
@if (field.control.hasError('required')) {
|
||||||
|
<small class="error">This field is required.</small>
|
||||||
|
}
|
||||||
|
@if (field.control.hasError('min')) {
|
||||||
|
<small class="error">The value must be higher.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
@else if (field.type == 'text-values') {
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{field.label}}</mat-label>
|
||||||
|
<mat-select [formControl]="field.control">
|
||||||
|
@for (value of field.values; track $index) {
|
||||||
|
<mat-option [value]="value">{{value}}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (field.control.invalid && (field.control.dirty || field.control.touched)) {
|
||||||
|
@if (field.control.hasError('required')) {
|
||||||
|
<small class="error">This field is required.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-card-actions class="actions">
|
||||||
|
@if (!isNew) {
|
||||||
|
<button mat-button
|
||||||
|
class="danger"
|
||||||
|
(click)="deleteAction(action)">
|
||||||
|
<mat-icon>delete</mat-icon>Delete
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button mat-button
|
||||||
|
class="neutral"
|
||||||
|
disabled="{{waitForResponse}}"
|
||||||
|
(click)="dialogRef.close()">
|
||||||
|
<mat-icon>cancel</mat-icon>Cancel
|
||||||
|
</button>
|
||||||
|
<button mat-button
|
||||||
|
class="confirm"
|
||||||
|
disabled="{{!formsDirty || !formsValidity || waitForResponse}}"
|
||||||
|
(click)="save()">
|
||||||
|
<mat-icon>save</mat-icon>Save
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
|
||||||
|
@if (responseError) {
|
||||||
|
<mat-card-footer>
|
||||||
|
<small class="error below">{{responseError}}</small>
|
||||||
|
</mat-card-footer>
|
||||||
|
}
|
||||||
|
</mat-card>
|
||||||
|
</content>
|
@ -0,0 +1,19 @@
|
|||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-auto-flow: row dense;
|
||||||
|
grid-gap: 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
background-color: #ea5151;
|
||||||
|
color: #ba1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdc-button~.mdc-button {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ActionItemEditComponent } from './action-item-edit.component';
|
||||||
|
|
||||||
|
describe('ActionItemEditComponent', () => {
|
||||||
|
let component: ActionItemEditComponent;
|
||||||
|
let fixture: ComponentFixture<ActionItemEditComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ActionItemEditComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ActionItemEditComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
308
src/app/actions/action-item-edit/action-item-edit.component.ts
Normal file
308
src/app/actions/action-item-edit/action-item-edit.component.ts
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import RedeemableAction from '../../shared/models/redeemable-action';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { createItemExistsInArrayValidator } from '../../shared/validators/item-exists-in-array';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'action-item-edit',
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './action-item-edit.component.html',
|
||||||
|
styleUrl: './action-item-edit.component.scss'
|
||||||
|
})
|
||||||
|
export class ActionItemEditComponent implements OnInit {
|
||||||
|
private readonly client = inject(HermesClientService);
|
||||||
|
private readonly data = inject<{ action: RedeemableAction, actions: RedeemableAction[] }>(MAT_DIALOG_DATA);
|
||||||
|
readonly dialogRef = inject(MatDialogRef<ActionItemEditComponent>);
|
||||||
|
|
||||||
|
action = this.data.action;
|
||||||
|
actions = this.data.actions;
|
||||||
|
|
||||||
|
readonly actionEntries: ({ [key: string]: any[] }) = {
|
||||||
|
'SLEEP': [
|
||||||
|
{
|
||||||
|
key: 'sleep',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Sleep (ms)',
|
||||||
|
control: new FormControl(this.action.data['sleep'], [Validators.required, Validators.min(500)]),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'APPEND_TO_FILE': [
|
||||||
|
{
|
||||||
|
key: 'file_path',
|
||||||
|
type: 'text',
|
||||||
|
label: 'File Path',
|
||||||
|
placeholder: '%userprofile%/Desktop/file.txt',
|
||||||
|
control: new FormControl(this.action.data['file_path'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'file_content',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Content',
|
||||||
|
placeholder: '%chatter% from %broadcaster%\'s chat says hi.',
|
||||||
|
control: new FormControl(this.action.data['file_content'], [Validators.required]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'WRITE_TO_FILE': [
|
||||||
|
{
|
||||||
|
key: 'file_path',
|
||||||
|
type: 'text',
|
||||||
|
label: 'File Path',
|
||||||
|
placeholder: '%userprofile%/Desktop/file.txt',
|
||||||
|
control: new FormControl(this.action.data['file_path'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'file_content',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Content',
|
||||||
|
placeholder: '%chatter% from %broadcaster%\'s chat says hi.',
|
||||||
|
control: new FormControl(this.action.data['file_content'], [Validators.required]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'OBS_TRANSFORM': [
|
||||||
|
{
|
||||||
|
key: 'scene_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'OBS Scene Name',
|
||||||
|
placeholder: 'Main Scene',
|
||||||
|
control: new FormControl(this.action.data['scene_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scene_item_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'OBS Scene Item Name',
|
||||||
|
placeholder: 'Item',
|
||||||
|
control: new FormControl(this.action.data['scene_item_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'position_x',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Position X',
|
||||||
|
placeholder: 'x + 50',
|
||||||
|
control: new FormControl(this.action.data['position_x'], []),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'position_y',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Position Y',
|
||||||
|
placeholder: 'x - 166',
|
||||||
|
control: new FormControl(this.action.data['position_y'], []),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rotation',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Rotation (in degrees)',
|
||||||
|
placeholder: 'mod(x + 45, 360)',
|
||||||
|
control: new FormControl(this.action.data['rotation'], []),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'AUDIO_FILE': [
|
||||||
|
{
|
||||||
|
key: 'file_path',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Audio File Path',
|
||||||
|
placeholder: '%userprofile%/Desktop/audio.mp3',
|
||||||
|
control: new FormControl(this.action.data['file_path'], [Validators.required]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'RANDOM_TTS_VOICE': [],
|
||||||
|
'SPECIFIC_TTS_VOICE': [
|
||||||
|
{
|
||||||
|
key: 'tts_voice',
|
||||||
|
type: 'text',
|
||||||
|
label: 'TTS Voice Name',
|
||||||
|
placeholder: 'Brian',
|
||||||
|
control: new FormControl(this.action.data['tts_voice'], [Validators.required, Validators.minLength(2)]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'TOGGLE_OBS_VISIBILITY': [
|
||||||
|
{
|
||||||
|
key: 'scene_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'OBS Scene Name',
|
||||||
|
placeholder: 'Main Scene',
|
||||||
|
control: new FormControl(this.action.data['scene_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scene_item_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'OBS Scene Item Name',
|
||||||
|
placeholder: 'Item',
|
||||||
|
control: new FormControl(this.action.data['scene_item_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'SPECIFIC_OBS_VISIBILITY': [
|
||||||
|
{
|
||||||
|
key: 'scene_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'OBS Scene Name',
|
||||||
|
placeholder: 'Main Scene',
|
||||||
|
control: new FormControl(this.action.data['scene_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scene_item_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'OBS Scene Item Name',
|
||||||
|
placeholder: 'Item',
|
||||||
|
control: new FormControl(this.action.data['scene_item_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'obs_visible',
|
||||||
|
type: 'text-values',
|
||||||
|
label: 'Visibility',
|
||||||
|
values: ['visible', 'hidden'],
|
||||||
|
control: new FormControl(this.action.data['scene_item_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'SPECIFIC_OBS_INDEX': [
|
||||||
|
{
|
||||||
|
key: 'scene_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'OBS Scene Name',
|
||||||
|
placeholder: 'Main Scene',
|
||||||
|
control: new FormControl(this.action.data['scene_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scene_item_name',
|
||||||
|
type: 'text',
|
||||||
|
label: 'OBS Scene Item Name',
|
||||||
|
placeholder: 'Item',
|
||||||
|
control: new FormControl(this.action.data['scene_item_name'], [Validators.required]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'obs_index',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Visibility',
|
||||||
|
control: new FormControl(this.action.data['scene_item_name'], [Validators.required, Validators.min(0)]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'NIGHTBOT_PLAY': [],
|
||||||
|
'VEADOTUBE_SET_STATE': [
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Veadotube State name',
|
||||||
|
placeholder: 'state #1',
|
||||||
|
control: new FormControl(this.action.data['state'], [Validators.required]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
readonly actionTypes = Object.keys(this.actionEntries);
|
||||||
|
|
||||||
|
isNew: boolean = true;
|
||||||
|
previousName: string = this.action.name;
|
||||||
|
responseError: string | undefined;
|
||||||
|
waitForResponse: boolean = false;
|
||||||
|
|
||||||
|
readonly formGroup = new FormGroup({
|
||||||
|
name: new FormControl(this.action.name, [Validators.required]),
|
||||||
|
type: new FormControl(this.action.type, [Validators.required]),
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.isNew = this.action.name.length <= 0;
|
||||||
|
this.previousName = this.action.name;
|
||||||
|
if (!this.isNew) {
|
||||||
|
this.formGroup.get('name')!.disable()
|
||||||
|
} else {
|
||||||
|
this.formGroup.get('name')?.addValidators(createItemExistsInArrayValidator(this.actions, a => a.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get exists(): boolean {
|
||||||
|
return this.actions.some(a => a.name == this.action.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
get formsValidity(): boolean {
|
||||||
|
return this.formGroup.valid && this.action.type in this.actionEntries
|
||||||
|
&& this.actionEntries[this.action.type].every(f => f.control.valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
get formsDirty(): boolean {
|
||||||
|
return this.formGroup.dirty || this.action.type in this.actionEntries
|
||||||
|
&& this.actionEntries[this.action.type].some(f => f.control.dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAction(action: RedeemableAction): void {
|
||||||
|
if (this.isNew || this.waitForResponse)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.waitForResponse = true;
|
||||||
|
this.responseError = undefined;
|
||||||
|
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'delete_redeemable_action' && d.d.request.data.name == this.action.name)
|
||||||
|
.subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
if (d.d.error) {
|
||||||
|
this.responseError = d.d.error;
|
||||||
|
} else {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => this.responseError = 'Something went wrong.',
|
||||||
|
complete: () => this.waitForResponse = false,
|
||||||
|
});
|
||||||
|
this.client.deleteRedeemableAction(action.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
if (!this.formGroup.dirty || this.formGroup.invalid || this.waitForResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.waitForResponse = true;
|
||||||
|
this.responseError = undefined;
|
||||||
|
|
||||||
|
const fields = this.actionEntries[this.action.type];
|
||||||
|
if (fields.some(f => f.control.invalid)) {
|
||||||
|
this.waitForResponse = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(this.action.type in this.actionEntries)) {
|
||||||
|
this.waitForResponse = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.action.name = this.formGroup.get('name')!.value!;
|
||||||
|
this.action.type = this.formGroup.get('type')!.value!;
|
||||||
|
this.action.data = {};
|
||||||
|
for (const entry of this.actionEntries[this.action.type]) {
|
||||||
|
this.action.data[entry.key] = entry.control.value!.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewAction = !this.action.user_id;
|
||||||
|
const requestType = isNewAction ? 'create_redeemable_action' : 'update_redeemable_action';
|
||||||
|
this.client.first((d: any) => d.op == 4 && d.d.request.type == requestType && d.d.data.name == this.action.name)
|
||||||
|
.subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
if (d.d.error) {
|
||||||
|
this.responseError = d.d.error;
|
||||||
|
} else {
|
||||||
|
this.dialogRef.close(this.action);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => this.responseError = 'Something went wrong.',
|
||||||
|
complete: () => this.waitForResponse = false,
|
||||||
|
});
|
||||||
|
if (isNewAction)
|
||||||
|
this.client.createRedeemableAction(this.action.name, this.action.type, false, this.action.data);
|
||||||
|
else
|
||||||
|
this.client.updateRedeemableAction(this.action.name, this.action.type, false, this.action.data);
|
||||||
|
}
|
||||||
|
}
|
15
src/app/actions/action-list/action-list.component.html
Normal file
15
src/app/actions/action-list/action-list.component.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<main>
|
||||||
|
@for (action of actions(); track action.name) {
|
||||||
|
<button type="button"
|
||||||
|
class="container"
|
||||||
|
(click)="modify(action)">
|
||||||
|
<span class="title">{{action.name}}</span>
|
||||||
|
<span class="subtitle">{{action.type}}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button"
|
||||||
|
class="container"
|
||||||
|
(click)="create()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</button>
|
||||||
|
</main>
|
62
src/app/actions/action-list/action-list.component.scss
Normal file
62
src/app/actions/action-list/action-list.component.scss
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
|
||||||
|
grid-auto-flow: row dense;
|
||||||
|
grid-gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
justify-self: center;
|
||||||
|
width: 80%;
|
||||||
|
|
||||||
|
& .container {
|
||||||
|
border-color: grey;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid grey;
|
||||||
|
padding: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .title {
|
||||||
|
font-size: medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .subtitle {
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
& article:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:1200px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:1650px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width:2200px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
23
src/app/actions/action-list/action-list.component.spec.ts
Normal file
23
src/app/actions/action-list/action-list.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ActionListComponent } from './action-list.component';
|
||||||
|
|
||||||
|
describe('ActionListComponent', () => {
|
||||||
|
let component: ActionListComponent;
|
||||||
|
let fixture: ComponentFixture<ActionListComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ActionListComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ActionListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
45
src/app/actions/action-list/action-list.component.ts
Normal file
45
src/app/actions/action-list/action-list.component.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Component, inject, input } from '@angular/core';
|
||||||
|
import { MatListModule } from '@angular/material/list';
|
||||||
|
import RedeemableAction from '../../shared/models/redeemable-action';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ActionItemEditComponent } from '../action-item-edit/action-item-edit.component';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'action-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatListModule,
|
||||||
|
MatSelectModule,
|
||||||
|
],
|
||||||
|
templateUrl: './action-list.component.html',
|
||||||
|
styleUrl: './action-list.component.scss'
|
||||||
|
})
|
||||||
|
export class ActionListComponent {
|
||||||
|
actions = input.required<RedeemableAction[]>({ alias: 'actions' });
|
||||||
|
|
||||||
|
readonly dialog = inject(MatDialog);
|
||||||
|
readonly client = inject(HermesClientService);
|
||||||
|
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.openDialog({ user_id: '', name: '', type: '', has_message: false, data: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
modify(action: RedeemableAction): void {
|
||||||
|
this.openDialog(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openDialog(action: RedeemableAction): void {
|
||||||
|
this.dialog.open(ActionItemEditComponent, {
|
||||||
|
data: { action: { user_id: action.user_id, name: action.name, type: action.type, data: action.data }, actions: this.actions() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
16
src/app/actions/actions.module.ts
Normal file
16
src/app/actions/actions.module.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { ActionsComponent } from './actions/actions.component';
|
||||||
|
import { ActionListComponent } from './action-list/action-list.component';
|
||||||
|
import { ActionItemEditComponent } from './action-item-edit/action-item-edit.component';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
ActionsComponent,
|
||||||
|
ActionListComponent,
|
||||||
|
ActionItemEditComponent,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ActionsModule { }
|
32
src/app/actions/actions/actions.component.html
Normal file
32
src/app/actions/actions/actions.component.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<content>
|
||||||
|
<h3>Redeemable Actions</h3>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<article>
|
||||||
|
<mat-form-field subscriptSizing="dynamic">
|
||||||
|
<mat-label>Filter by type</mat-label>
|
||||||
|
<mat-select value="0"
|
||||||
|
(selectionChange)="filter = filters[$event.value]">
|
||||||
|
<mat-select-trigger>
|
||||||
|
<mat-icon matPrefix>filter_list</mat-icon> {{filter.name}}
|
||||||
|
</mat-select-trigger>
|
||||||
|
@for (item of filters; track item.name) {
|
||||||
|
<mat-option value="{{$index}}">{{item.name}}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<mat-form-field subscriptSizing="dynamic">
|
||||||
|
<mat-label>Search</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="text"
|
||||||
|
placeholder="Name of action"
|
||||||
|
[formControl]="searchControl" />
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<action-list class="list center"
|
||||||
|
[actions]="actions" />
|
||||||
|
</content>
|
39
src/app/actions/actions/actions.component.scss
Normal file
39
src/app/actions/actions/actions.component.scss
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
content,
|
||||||
|
h3 {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 70%;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
@media (max-width:1250px) {
|
||||||
|
display: block;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
23
src/app/actions/actions/actions.component.spec.ts
Normal file
23
src/app/actions/actions/actions.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ActionsComponent } from './actions.component';
|
||||||
|
|
||||||
|
describe('ActionsComponent', () => {
|
||||||
|
let component: ActionsComponent;
|
||||||
|
let fixture: ComponentFixture<ActionsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ActionsComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ActionsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
75
src/app/actions/actions/actions.component.ts
Normal file
75
src/app/actions/actions/actions.component.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { ActionListComponent } from "../action-list/action-list.component";
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import RedeemableAction from '../../shared/models/redeemable-action';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import RedeemableActionService from '../../shared/services/redeemable-action.service';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { containsLettersInOrder } from '../../shared/utils/string-compare';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
interface IActionFilter {
|
||||||
|
name: string
|
||||||
|
filter: (action: any) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'actions',
|
||||||
|
imports: [
|
||||||
|
ActionListComponent,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './actions.component.html',
|
||||||
|
styleUrl: './actions.component.scss'
|
||||||
|
})
|
||||||
|
export class ActionsComponent implements OnInit, OnDestroy {
|
||||||
|
filters: IActionFilter[] = [
|
||||||
|
{ name: 'All', filter: _ => true },
|
||||||
|
{ name: 'Local File', filter: data => data.type.includes('_FILE') },
|
||||||
|
{ name: 'Nightbot', filter: data => data.type.includes('NIGHTBOT_') },
|
||||||
|
{ name: 'OBS', filter: data => data.type.includes('OBS_') },
|
||||||
|
{ name: 'Sleep', filter: data => data.type == "SLEEP" },
|
||||||
|
{ name: 'TTS', filter: data => data.type.includes('TTS') },
|
||||||
|
{ name: 'Veadotube', filter: data => data.type.includes('VEADOTUBE') },
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly client = inject(HermesClientService);
|
||||||
|
private readonly redeemableActionService = inject(RedeemableActionService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
private readonly subscriptions: (Subscription | undefined)[] = [];
|
||||||
|
|
||||||
|
filter = this.filters[0];
|
||||||
|
searchControl = new FormControl<string>('');
|
||||||
|
_actions: RedeemableAction[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.data.subscribe(data => this._actions = data['redeemableActions'] || []);
|
||||||
|
|
||||||
|
this.subscriptions.push(this.redeemableActionService.changes$?.subscribe(a => this._actions = a));
|
||||||
|
|
||||||
|
this.client.fetchRedeemableActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subscriptions.filter(s => s).forEach(s => s?.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
get actions(): RedeemableAction[] {
|
||||||
|
const searchLower = this.searchControl.value!.toLowerCase();
|
||||||
|
return this._actions.filter(this.filter.filter)
|
||||||
|
.filter((action) => containsLettersInOrder(action.name.toLowerCase(), searchLower));
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,11 @@
|
|||||||
<main class="main">
|
<main>
|
||||||
<navigation class="navigation" />
|
<topbar class="top" />
|
||||||
|
<div class="below-topbar"
|
||||||
|
[class.grid]="isSidebarOpen"
|
||||||
|
[class.full]="!isSidebarOpen">
|
||||||
|
@if (isSidebarOpen) {
|
||||||
|
<sidebar class="navigation" />
|
||||||
|
}
|
||||||
<router-outlet class="content" />
|
<router-outlet class="content" />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
@ -1,4 +1,8 @@
|
|||||||
.main {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 20em 0px 1fr;
|
grid-template-columns: 20em 0px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
@ -1,64 +1,132 @@
|
|||||||
import { CommonModule, DatePipe, isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { Component, OnInit, Inject, PLATFORM_ID, NgZone, OnDestroy } from '@angular/core';
|
import { Component, OnInit, Inject, PLATFORM_ID, NgZone, OnDestroy, inject, HostBinding } from '@angular/core';
|
||||||
import { Router, RouterOutlet } from '@angular/router';
|
import { Router, RouterOutlet } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { HermesClientService } from './hermes-client.service';
|
import { HermesClientService } from './hermes-client.service';
|
||||||
import { AuthUserGuard } from './shared/auth/auth.user.guard'
|
import { AuthUserGuard } from './shared/auth/auth.user.guard'
|
||||||
import { Subscription } from 'rxjs';
|
import { first, Subscription, timeout } from 'rxjs';
|
||||||
import { PolicyComponent } from "./policy/policy.component";
|
|
||||||
import { NavigationComponent } from "./navigation/navigation.component";
|
|
||||||
import EventService from './shared/services/EventService';
|
import EventService from './shared/services/EventService';
|
||||||
import { ApiAuthenticationService } from './shared/services/api/api-authentication.service';
|
import { ApiAuthenticationService } from './shared/services/api/api-authentication.service';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { ApiKeyService } from './shared/services/api/api-key.service';
|
||||||
|
import { ThemeService } from './shared/services/theme.service';
|
||||||
|
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { SidebarComponent } from "./navigation/sidebar/sidebar.component";
|
||||||
|
import { Topbar as TopbarComponent } from "./navigation/topbar/topbar.component";
|
||||||
|
import ApiKey from './shared/models/api-key';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, CommonModule, FormsModule, PolicyComponent, NavigationComponent],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
RouterOutlet,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatToolbarModule,
|
||||||
|
SidebarComponent,
|
||||||
|
TopbarComponent,
|
||||||
|
],
|
||||||
providers: [AuthUserGuard],
|
providers: [AuthUserGuard],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss'
|
styleUrl: './app.component.scss'
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit, OnDestroy {
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
private isBrowser: boolean;
|
private readonly keyService = inject(ApiKeyService);
|
||||||
|
private readonly overlayContainer = inject(OverlayContainer);
|
||||||
|
private readonly themeService = inject(ThemeService);
|
||||||
|
|
||||||
private ngZone: NgZone;
|
private ngZone: NgZone;
|
||||||
private subscriptions: Subscription[];
|
private subscriptions: Subscription[];
|
||||||
pipe = new DatePipe('en-US')
|
|
||||||
|
|
||||||
|
authentication = inject(ApiAuthenticationService);
|
||||||
|
isSidebarOpen: boolean = true
|
||||||
|
|
||||||
|
@HostBinding('class.dark-theme')
|
||||||
|
get isDarkTheme() {
|
||||||
|
return this.themeService.isDarkTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostBinding('class.light-theme')
|
||||||
|
get isLightTheme() {
|
||||||
|
return this.themeService.isLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private auth: ApiAuthenticationService, private client: HermesClientService, private events: EventService, private router: Router, ngZone: NgZone, @Inject(PLATFORM_ID) private platformId: Object) {
|
constructor(private auth: ApiAuthenticationService, private client: HermesClientService, private events: EventService, private router: Router, ngZone: NgZone, @Inject(PLATFORM_ID) private platformId: Object) {
|
||||||
this.ngZone = ngZone;
|
this.ngZone = ngZone;
|
||||||
this.isBrowser = isPlatformBrowser(this.platformId);
|
|
||||||
this.subscriptions = [];
|
this.subscriptions = [];
|
||||||
|
|
||||||
|
this.subscriptions.push(this.events.listen('tts_login_ack', async _ => {
|
||||||
|
const url = router.url;
|
||||||
|
const params = router.parseUrl(url).queryParams;
|
||||||
|
const redirect = params['rd'];
|
||||||
|
|
||||||
|
if (redirect && !(url.startsWith(redirect) || redirect.startsWith(url))) {
|
||||||
|
await this.router.navigate([redirect]);
|
||||||
|
} else if (url == '/' || url.startsWith('/login') || url.startsWith('/tts-login')) {
|
||||||
|
await this.router.navigate(['policies']);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.addSubscription(this.events.listen('login', () => {
|
||||||
|
this.keyService.fetch()
|
||||||
|
.pipe(timeout(3000), first())
|
||||||
|
.subscribe(async (d: ApiKey[]) => {
|
||||||
|
if (d.length > 0)
|
||||||
|
this.client.login(d[0].id);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.subscriptions.push(this.events.listen('tts_logoff', async _ => await this.router.navigate(['tts-login'])));
|
||||||
|
this.subscriptions.push(this.events.listen('toggle_sidebar', () => this.isSidebarOpen = !this.isSidebarOpen))
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (!this.isBrowser)
|
if (!isPlatformBrowser(this.platformId))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.auth.update();
|
this.auth.update(localStorage.getItem('jwt'));
|
||||||
|
|
||||||
this.addSubscription(this.events.listen('logoff', (message) => {
|
this.subscriptions.push(this.events.listen('login', async () => await this.router.navigate(['tts-login'])));
|
||||||
|
|
||||||
|
this.addSubscription(this.events.listen('logoff', async (message) => {
|
||||||
localStorage.removeItem('jwt');
|
localStorage.removeItem('jwt');
|
||||||
if (!document.location.href.includes('/login')) {
|
if (!document.location.href.includes('/login')) {
|
||||||
this.router.navigate(['/login?warning=' + message]);
|
if (message)
|
||||||
|
await this.router.navigate(['login'], {
|
||||||
|
queryParams: { message: message }
|
||||||
|
});
|
||||||
|
else
|
||||||
|
await this.router.navigate(['login']);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.addSubscription(this.events.listen('login', (_) => {
|
let currentTheme = localStorage.getItem('ui-theme') ?? this.themeService.theme;
|
||||||
if (document.location.href.includes('/login')) {
|
if (currentTheme == 'light' || currentTheme == 'dark') {
|
||||||
this.router.navigate(['/tts-login']);
|
this.themeService.theme = currentTheme;
|
||||||
|
} else {
|
||||||
|
this.themeService.theme = 'dark';
|
||||||
}
|
}
|
||||||
|
this.overlayContainer.getContainerElement().classList.add(this.themeService.theme + '-theme');
|
||||||
|
|
||||||
|
this.addSubscription(this.events.listen('theme_change', data => {
|
||||||
|
const classList = this.overlayContainer.getContainerElement().classList;
|
||||||
|
classList.remove(data.previous_theme + '-theme');
|
||||||
|
classList.add(data.current_theme + '-theme');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.client.connect();
|
|
||||||
this.ngZone.runOutsideAngular(() => setInterval(() => this.client.heartbeat(), 15000));
|
this.ngZone.runOutsideAngular(() => setInterval(() => this.client.heartbeat(), 15000));
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
for (let s of this.subscriptions) {
|
for (let s of this.subscriptions) {
|
||||||
|
if (s) {
|
||||||
s.unsubscribe();
|
s.unsubscribe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private addSubscription(s: Subscription) {
|
private addSubscription(s: Subscription) {
|
||||||
this.subscriptions.push(s);
|
this.subscriptions.push(s);
|
||||||
|
@ -13,11 +13,10 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
withInterceptors([(req: HttpRequest<unknown>, next: HttpHandlerFn) => {
|
withInterceptors([(req: HttpRequest<unknown>, next: HttpHandlerFn) => {
|
||||||
console.log(req.url);
|
|
||||||
return next(req);
|
return next(req);
|
||||||
}])
|
}])
|
||||||
),
|
),
|
||||||
provideOAuthClient(),
|
provideOAuthClient(),
|
||||||
provideClientHydration(), provideAnimationsAsync()
|
provideClientHydration(), provideAnimationsAsync(), provideAnimationsAsync()
|
||||||
]
|
]
|
||||||
};
|
};
|
13
src/app/app.module.ts
Normal file
13
src/app/app.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { TtsFiltersModule } from './tts-filters/tts-filters.module';
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AppModule { }
|
@ -1,27 +1,124 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { PolicyComponent } from './policy/policy.component';
|
import { PolicyComponent } from './policies/policy/policy.component';
|
||||||
import { AuthUserGuard } from './shared/auth/auth.user.guard';
|
import { AuthUserGuard } from './shared/auth/auth.user.guard';
|
||||||
import { LoginComponent } from './login/login.component';
|
import { LoginComponent } from './auth/login/login.component';
|
||||||
import { TtsLoginComponent } from './tts-login/tts-login.component';
|
|
||||||
import { TwitchAuthCallbackComponent } from './twitch-auth-callback/twitch-auth-callback.component';
|
import { TwitchAuthCallbackComponent } from './twitch-auth-callback/twitch-auth-callback.component';
|
||||||
|
import { FiltersComponent } from './tts-filters/filters/filters.component';
|
||||||
|
import { AuthAdminGuard } from './shared/auth/auth.admin.guard';
|
||||||
|
import { AuthVisitorGuard } from './shared/auth/auth.visitor.guard';
|
||||||
|
import { ActionsComponent } from './actions/actions/actions.component';
|
||||||
|
import { RedemptionsComponent } from './redemptions/redemptions/redemptions.component';
|
||||||
|
import RedemptionResolver from './shared/resolvers/redemption-resolver';
|
||||||
|
import TwitchRedemptionResolver from './shared/resolvers/twitch-redemption-resolver';
|
||||||
|
import RedeemableActionResolver from './shared/resolvers/redeemable-action-resolver';
|
||||||
|
import TtsFilterResolver from './shared/resolvers/tts-filter-resolver';
|
||||||
|
import ApiKeyResolver from './shared/resolvers/api-key-resolver';
|
||||||
|
import GroupResolver from './shared/resolvers/group-resolver';
|
||||||
|
import PolicyResolver from './shared/resolvers/policy-resolver';
|
||||||
|
import { GroupsComponent } from './groups/groups/groups.component';
|
||||||
|
import { GroupPageComponent } from './groups/group-page/group-page.component';
|
||||||
|
import GroupChatterResolver from './shared/resolvers/group-chatter-resolver';
|
||||||
|
import PermissionResolver from './shared/resolvers/permission-resolver';
|
||||||
|
import { ConnectionsComponent } from './connections/connections/connections.component';
|
||||||
|
import ConnectionResolver from './shared/resolvers/connection-resolver';
|
||||||
|
import { ConnectionCallbackComponent } from './connections/callback/callback.component';
|
||||||
|
import { KeysComponent } from './keys/keys/keys.component';
|
||||||
|
import { TtsLoginComponent } from './auth/tts-login/tts-login.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: 'policies',
|
path: 'actions',
|
||||||
component: PolicyComponent,
|
component: ActionsComponent,
|
||||||
canActivate: [AuthUserGuard],
|
canActivate: [AuthUserGuard],
|
||||||
|
resolve: {
|
||||||
|
redeemableActions: RedeemableActionResolver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'auth',
|
||||||
|
component: TwitchAuthCallbackComponent,
|
||||||
|
canActivate: [AuthVisitorGuard],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'connections',
|
||||||
|
component: ConnectionsComponent,
|
||||||
|
canActivate: [AuthUserGuard],
|
||||||
|
resolve: {
|
||||||
|
connections: ConnectionResolver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'connections/callback',
|
||||||
|
component: ConnectionCallbackComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'groups',
|
||||||
|
component: GroupsComponent,
|
||||||
|
canActivate: [AuthUserGuard],
|
||||||
|
resolve: {
|
||||||
|
groups: GroupResolver,
|
||||||
|
chatters: GroupChatterResolver,
|
||||||
|
policies: PolicyResolver,
|
||||||
|
permissions: PermissionResolver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'filters',
|
||||||
|
component: FiltersComponent,
|
||||||
|
canActivate: [AuthUserGuard],
|
||||||
|
resolve: {
|
||||||
|
filters: TtsFilterResolver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'groups/:id',
|
||||||
|
component: GroupPageComponent,
|
||||||
|
canActivate: [AuthUserGuard],
|
||||||
|
resolve: {
|
||||||
|
groups: GroupResolver,
|
||||||
|
chatters: GroupChatterResolver,
|
||||||
|
policies: PolicyResolver,
|
||||||
|
permissions: PermissionResolver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'keys',
|
||||||
|
component: KeysComponent,
|
||||||
|
canActivate: [AuthUserGuard],
|
||||||
|
resolve: {
|
||||||
|
keys: ApiKeyResolver,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
component: LoginComponent,
|
component: LoginComponent,
|
||||||
|
canActivate: [AuthVisitorGuard],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policies',
|
||||||
|
component: PolicyComponent,
|
||||||
|
canActivate: [AuthUserGuard],
|
||||||
|
resolve: {
|
||||||
|
groups: GroupResolver,
|
||||||
|
policies: PolicyResolver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'redemptions',
|
||||||
|
component: RedemptionsComponent,
|
||||||
|
canActivate: [AuthUserGuard],
|
||||||
|
resolve: {
|
||||||
|
redeemableActions: RedeemableActionResolver,
|
||||||
|
redemptions: RedemptionResolver,
|
||||||
|
twitchRedemptions: TwitchRedemptionResolver,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'tts-login',
|
path: 'tts-login',
|
||||||
component: TtsLoginComponent,
|
component: TtsLoginComponent,
|
||||||
canActivate: [AuthUserGuard],
|
canActivate: [AuthUserGuard],
|
||||||
},
|
resolve: {
|
||||||
{
|
keys: ApiKeyResolver,
|
||||||
path: 'auth',
|
|
||||||
component: TwitchAuthCallbackComponent
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
14
src/app/auth/auth.module.ts
Normal file
14
src/app/auth/auth.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { LoginComponent } from './login/login.component';
|
||||||
|
import { ImpersonationComponent } from './impersonation/impersonation.component';
|
||||||
|
import { UserCardComponent } from './user-card/user-card.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
LoginComponent,
|
||||||
|
ImpersonationComponent,
|
||||||
|
UserCardComponent,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AuthModule { }
|
12
src/app/auth/impersonation/impersonation.component.html
Normal file
12
src/app/auth/impersonation/impersonation.component.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@if (isAdmin()) {
|
||||||
|
<mat-form-field class="mat-small"
|
||||||
|
subscriptSizing="dynamic">
|
||||||
|
<mat-label>User to impersonate</mat-label>
|
||||||
|
<mat-select [formControl]="impersonationControl">
|
||||||
|
<mat-option [value]="auth.getUserId()">{{getUsername()}}</mat-option>
|
||||||
|
@for (user of (users$ | async | excludeById : auth.getUserId()); track user.id) {
|
||||||
|
<mat-option [value]="user.id">{{ user.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
5
src/app/auth/impersonation/impersonation.component.scss
Normal file
5
src/app/auth/impersonation/impersonation.component.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
88
src/app/auth/impersonation/impersonation.component.ts
Normal file
88
src/app/auth/impersonation/impersonation.component.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import EventService from '../../shared/services/EventService';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { UserService } from '../../shared/services/user.service';
|
||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
|
import { ExcludeByIdPipe } from '../../shared/pipes/exclude-by-id.pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'impersonation',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
ExcludeByIdPipe,
|
||||||
|
MatCardModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './impersonation.component.html',
|
||||||
|
styleUrl: './impersonation.component.scss'
|
||||||
|
})
|
||||||
|
export class ImpersonationComponent implements OnInit {
|
||||||
|
private readonly client = inject(HermesClientService);
|
||||||
|
private readonly userService = inject(UserService);
|
||||||
|
private readonly events = inject(EventService);
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
readonly auth = inject(ApiAuthenticationService);
|
||||||
|
|
||||||
|
impersonationControl = new FormControl<string>(this.auth.getUserId());
|
||||||
|
users$ = this.userService.fetch();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!this.auth.isAdmin()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.users$.subscribe(users => {
|
||||||
|
const id = this.auth.getImpersonatedId();
|
||||||
|
if (id && users.find(u => u.id == id)) {
|
||||||
|
this.impersonationControl.setValue(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.impersonationControl.valueChanges.subscribe((impersonationId) => {
|
||||||
|
if (impersonationId == this.auth.getImpersonatedId())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (impersonationId == this.auth.getUserId()) {
|
||||||
|
this.http.delete(environment.API_HOST + '/admin/impersonate', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
impersonation: impersonationId
|
||||||
|
}
|
||||||
|
}).subscribe(async (data: any) => {
|
||||||
|
this.client.disconnect(true);
|
||||||
|
this.events.emit('impersonation', impersonationId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.http.put(environment.API_HOST + '/admin/impersonate', {
|
||||||
|
impersonation: impersonationId
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
|
||||||
|
}
|
||||||
|
}).subscribe(async (data: any) => {
|
||||||
|
this.client.disconnect(true);
|
||||||
|
this.events.emit('impersonation', impersonationId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isAdmin() {
|
||||||
|
return this.auth.isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getUsername() {
|
||||||
|
return this.auth.getUsername();
|
||||||
|
}
|
||||||
|
}
|
6
src/app/auth/login-button/login-button.component.html
Normal file
6
src/app/auth/login-button/login-button.component.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<button mat-icon-button
|
||||||
|
class="neutral"
|
||||||
|
matTooltip="Navigate to the log in page"
|
||||||
|
(click)="login()">
|
||||||
|
<mat-icon>login</mat-icon>
|
||||||
|
</button>
|
23
src/app/auth/login-button/login-button.component.spec.ts
Normal file
23
src/app/auth/login-button/login-button.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LoginButtonComponent } from './login-button.component';
|
||||||
|
|
||||||
|
describe('LoginButtonComponent', () => {
|
||||||
|
let component: LoginButtonComponent;
|
||||||
|
let fixture: ComponentFixture<LoginButtonComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [LoginButtonComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LoginButtonComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
28
src/app/auth/login-button/login-button.component.ts
Normal file
28
src/app/auth/login-button/login-button.component.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'login-button',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './login-button.component.html',
|
||||||
|
styleUrl: './login-button.component.scss'
|
||||||
|
})
|
||||||
|
export class LoginButtonComponent {
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
login() {
|
||||||
|
this.router.navigate(['login']);
|
||||||
|
}
|
||||||
|
}
|
22
src/app/auth/login/login.component.html
Normal file
22
src/app/auth/login/login.component.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<div class="login">
|
||||||
|
<mat-card class="outer"
|
||||||
|
appearance="outlined">
|
||||||
|
<mat-card-header>
|
||||||
|
<h1 class="title">Login</h1>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content class="services">
|
||||||
|
<p>Log in with your favorite livestream service</p>
|
||||||
|
|
||||||
|
<a>
|
||||||
|
<mat-card appearance="outlined"
|
||||||
|
class="twitch"
|
||||||
|
(click)="login()">
|
||||||
|
<mat-card-content>
|
||||||
|
Twitch
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</a>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
36
src/app/auth/login/login.component.scss
Normal file
36
src/app/auth/login/login.component.scss
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
.login {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-content {
|
||||||
|
align-self: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services p {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch {
|
||||||
|
background-color: #a970ff;
|
||||||
|
color: black;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
16
src/app/auth/login/login.component.ts
Normal file
16
src/app/auth/login/login.component.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [MatCardModule],
|
||||||
|
templateUrl: './login.component.html',
|
||||||
|
styleUrl: './login.component.scss'
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
login() {
|
||||||
|
document.location.replace(environment.API_HOST + '/auth');
|
||||||
|
}
|
||||||
|
}
|
6
src/app/auth/logoff-button/logoff-button.component.html
Normal file
6
src/app/auth/logoff-button/logoff-button.component.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<button mat-icon-button
|
||||||
|
class="danger"
|
||||||
|
matTooltip="Log off"
|
||||||
|
(click)="logoff()">
|
||||||
|
<mat-icon>logout</mat-icon>
|
||||||
|
</button>
|
23
src/app/auth/logoff-button/logoff-button.component.spec.ts
Normal file
23
src/app/auth/logoff-button/logoff-button.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LogoffButtonComponent } from './logoff-button.component';
|
||||||
|
|
||||||
|
describe('LogoffButtonComponent', () => {
|
||||||
|
let component: LogoffButtonComponent;
|
||||||
|
let fixture: ComponentFixture<LogoffButtonComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [LogoffButtonComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LogoffButtonComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
28
src/app/auth/logoff-button/logoff-button.component.ts
Normal file
28
src/app/auth/logoff-button/logoff-button.component.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'logoff-button',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './logoff-button.component.html',
|
||||||
|
styleUrl: './logoff-button.component.scss'
|
||||||
|
})
|
||||||
|
export class LogoffButtonComponent {
|
||||||
|
private readonly auth = inject(ApiAuthenticationService);
|
||||||
|
|
||||||
|
logoff() {
|
||||||
|
this.auth.logout();
|
||||||
|
}
|
||||||
|
}
|
25
src/app/auth/tts-login/tts-login.component.html
Normal file
25
src/app/auth/tts-login/tts-login.component.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<main>
|
||||||
|
<mat-card class="main-card">
|
||||||
|
<mat-card-header class="header">
|
||||||
|
<mat-card-title-group>
|
||||||
|
<mat-card-title>TTS Login</mat-card-title>
|
||||||
|
<mat-card-subtitle>Web Access to Tom-to-Speech</mat-card-subtitle>
|
||||||
|
</mat-card-title-group>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content class="content">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>API Key</mat-label>
|
||||||
|
<mat-select [formControl]="keyControl">
|
||||||
|
@for (key of api_keys; track key.id) {
|
||||||
|
<mat-option [value]="key.id">{{key.label}}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</mat-card-content>
|
||||||
|
<mat-card-actions>
|
||||||
|
<button mat-raised-button
|
||||||
|
[disabled]="disabled"
|
||||||
|
(click)="login()">Log In</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</main>
|
7
src/app/auth/tts-login/tts-login.component.scss
Normal file
7
src/app/auth/tts-login/tts-login.component.scss
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
main {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
71
src/app/auth/tts-login/tts-login.component.ts
Normal file
71
src/app/auth/tts-login/tts-login.component.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import EventService from '../../shared/services/EventService';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { ApiKeyService } from '../../shared/services/api/api-key.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'tts-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './tts-login.component.html',
|
||||||
|
styleUrl: './tts-login.component.scss'
|
||||||
|
})
|
||||||
|
export class TtsLoginComponent implements OnInit, OnDestroy {
|
||||||
|
private readonly client = inject(HermesClientService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly keyService = inject(ApiKeyService);
|
||||||
|
private readonly eventService = inject(EventService);
|
||||||
|
|
||||||
|
keyControl = new FormControl<string | null>('');
|
||||||
|
api_keys: { id: string, label: string }[] = [];
|
||||||
|
subscriptions: (Subscription | null)[] = [];
|
||||||
|
disabled: boolean = false;
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.data.subscribe(d => this.api_keys = d['keys']);
|
||||||
|
|
||||||
|
this.subscriptions.push(this.eventService.listen('impersonation', _ => this.reset()));
|
||||||
|
this.subscriptions.push(this.eventService.listen('logoff', impersonation => {
|
||||||
|
if (!impersonation)
|
||||||
|
this.reset();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
for (let subscription of this.subscriptions) {
|
||||||
|
if (subscription) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
login(): void {
|
||||||
|
if (!this.keyControl.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.client.login(this.keyControl.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.disabled = true;
|
||||||
|
this.api_keys = [];
|
||||||
|
this.keyService.fetch().subscribe(keys => {
|
||||||
|
this.api_keys = keys;
|
||||||
|
this.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
23
src/app/auth/user-card/user-card.component.html
Normal file
23
src/app/auth/user-card/user-card.component.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
@if (auth.isAuthenticated()) {
|
||||||
|
<main>
|
||||||
|
<mat-card appearance="outlined"
|
||||||
|
class="card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{username}}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<impersonation />
|
||||||
|
</mat-card-content>
|
||||||
|
<mat-card-actions class="actions">
|
||||||
|
<div>
|
||||||
|
@if (isTTSLoggedIn) {
|
||||||
|
<button mat-raised-button
|
||||||
|
(click)="client.disconnect()"><span class="disconnect">Disconnect</span></button>
|
||||||
|
}
|
||||||
|
<button mat-raised-button
|
||||||
|
(click)="auth.logout()"><span class="logoff">Log Off</span></button>
|
||||||
|
</div>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
</main>
|
||||||
|
}
|
24
src/app/auth/user-card/user-card.component.scss
Normal file
24
src/app/auth/user-card/user-card.component.scss
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 0 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnect,
|
||||||
|
.logoff {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdc-button~.mdc-button {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
23
src/app/auth/user-card/user-card.component.spec.ts
Normal file
23
src/app/auth/user-card/user-card.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { UserCardComponent } from './user-card.component';
|
||||||
|
|
||||||
|
describe('UserCardComponent', () => {
|
||||||
|
let component: UserCardComponent;
|
||||||
|
let fixture: ComponentFixture<UserCardComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [UserCardComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(UserCardComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
26
src/app/auth/user-card/user-card.component.ts
Normal file
26
src/app/auth/user-card/user-card.component.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ImpersonationComponent } from '../impersonation/impersonation.component';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'user-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ImpersonationComponent, MatButtonModule, MatCardModule],
|
||||||
|
templateUrl: './user-card.component.html',
|
||||||
|
styleUrl: './user-card.component.scss'
|
||||||
|
})
|
||||||
|
export class UserCardComponent {
|
||||||
|
auth = inject(ApiAuthenticationService);
|
||||||
|
client = inject(HermesClientService);
|
||||||
|
|
||||||
|
get isTTSLoggedIn() {
|
||||||
|
return this.client.logged_in;
|
||||||
|
}
|
||||||
|
|
||||||
|
get username() {
|
||||||
|
return this.auth.getUsername();
|
||||||
|
}
|
||||||
|
}
|
3
src/app/connections/callback/callback.component.html
Normal file
3
src/app/connections/callback/callback.component.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@if (success || failure) {
|
||||||
|
<p>Automatically going back to the connections page soon...</p>
|
||||||
|
}
|
23
src/app/connections/callback/callback.component.spec.ts
Normal file
23
src/app/connections/callback/callback.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { CallbackComponent } from './callback.component';
|
||||||
|
|
||||||
|
describe('CallbackComponent', () => {
|
||||||
|
let component: CallbackComponent;
|
||||||
|
let fixture: ComponentFixture<CallbackComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CallbackComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(CallbackComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
52
src/app/connections/callback/callback.component.ts
Normal file
52
src/app/connections/callback/callback.component.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'connection-callback',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './callback.component.html',
|
||||||
|
styleUrl: './callback.component.scss'
|
||||||
|
})
|
||||||
|
export class ConnectionCallbackComponent implements OnInit {
|
||||||
|
private readonly client = inject(HermesClientService);
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
success: boolean = false;
|
||||||
|
failure: boolean = false;
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
const url = this.router.parseUrl(this.router.url);
|
||||||
|
if (!url.fragment) {
|
||||||
|
this.failure = true;
|
||||||
|
await this.router.navigate(['connections']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramsParts = url.fragment.split('&');
|
||||||
|
const params = Object.assign({}, ...paramsParts.map((p: string) => ({ [p.split('=')[0]]: p.split('=')[1] })));
|
||||||
|
|
||||||
|
if (!params.access_token || !params.scope || !params.state || !params.token_type) {
|
||||||
|
this.failure = true;
|
||||||
|
await this.router.navigate(['connections']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.http.get(`${environment.API_HOST}/auth/connections?token=${params['access_token']}&state=${params['state']}&expires_in=${params['expires_in']}`).subscribe({
|
||||||
|
next: async (d: any) => {
|
||||||
|
const data = d.data;
|
||||||
|
this.success = true;
|
||||||
|
|
||||||
|
await setTimeout(async () => {
|
||||||
|
this.client.createConnection(data.connection.name, data.connection.type, data.connection.clientId, params['access_token'], data.connection.grantType, params['scope'], data.expires_at);
|
||||||
|
await this.router.navigate(['connections'])
|
||||||
|
}, 2000)
|
||||||
|
},
|
||||||
|
error: async () => await this.router.navigate(['connections'])
|
||||||
|
});
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
<mat-card>
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title-group>
|
||||||
|
<mat-card-title>Add Connection</mat-card-title>
|
||||||
|
<mat-card-subtitle></mat-card-subtitle>
|
||||||
|
</mat-card-title-group>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Connection Name</mat-label>
|
||||||
|
<input matInput
|
||||||
|
[formControl]="nameControl" />
|
||||||
|
@if (nameControl.invalid && (nameControl.dirty || nameControl.touched)) {
|
||||||
|
@if (nameControl.hasError('required')) {
|
||||||
|
<small class="error">This field is required.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Client Type</mat-label>
|
||||||
|
<mat-select [formControl]="typeControl">
|
||||||
|
<mat-option value="nightbot">Nightbot</mat-option>
|
||||||
|
<mat-option value="twitch">Twitch</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
@if (typeControl.invalid && (typeControl.dirty || typeControl.touched)) {
|
||||||
|
@if (typeControl.hasError('required')) {
|
||||||
|
<small class="error">This field is required.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Client Id</mat-label>
|
||||||
|
<input matInput
|
||||||
|
[formControl]="clientIdControl" />
|
||||||
|
@if (clientIdControl.invalid && (clientIdControl.dirty || clientIdControl.touched)) {
|
||||||
|
@if (clientIdControl.hasError('required')) {
|
||||||
|
<small class="error">This field is required.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-card-actions class="actions">
|
||||||
|
<button mat-button
|
||||||
|
class="neutral"
|
||||||
|
disabled="{{waitForResponse}}"
|
||||||
|
(click)="dialogRef.close()">Cancel</button>
|
||||||
|
<button mat-button
|
||||||
|
class="confirm"
|
||||||
|
disabled="{{!form.dirty || form.invalid || waitForResponse}}"
|
||||||
|
(click)="submit()">Add</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
|
||||||
|
@if (responseError) {
|
||||||
|
<mat-card-footer>
|
||||||
|
<small class="error below">{{responseError}}</small>
|
||||||
|
</mat-card-footer>
|
||||||
|
}
|
||||||
|
</mat-card>
|
@ -0,0 +1,12 @@
|
|||||||
|
.mat-mdc-form-field {
|
||||||
|
display: block;
|
||||||
|
margin: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-actions {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-actions > button {
|
||||||
|
margin: 1em;
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ConnectionItemEditComponent } from './connection-item-edit.component';
|
||||||
|
|
||||||
|
describe('ConnectionItemEditComponent', () => {
|
||||||
|
let component: ConnectionItemEditComponent;
|
||||||
|
let fixture: ComponentFixture<ConnectionItemEditComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ConnectionItemEditComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConnectionItemEditComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,70 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Component, Inject, inject } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'connection-item-edit',
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './connection-item-edit.component.html',
|
||||||
|
styleUrl: './connection-item-edit.component.scss'
|
||||||
|
})
|
||||||
|
export class ConnectionItemEditComponent {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly data = inject<{ name: string }>(MAT_DIALOG_DATA);
|
||||||
|
readonly dialogRef = inject(MatDialogRef<ConnectionItemEditComponent>);
|
||||||
|
|
||||||
|
readonly nameControl = new FormControl<string>('', [Validators.required]);
|
||||||
|
readonly clientIdControl = new FormControl<string>('', [Validators.required]);
|
||||||
|
readonly typeControl = new FormControl<string>('', [Validators.required]);
|
||||||
|
readonly form = new FormGroup({
|
||||||
|
name: this.nameControl,
|
||||||
|
clientId: this.clientIdControl,
|
||||||
|
type: this.typeControl,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
responseError: string | undefined;
|
||||||
|
waitForResponse = false;
|
||||||
|
|
||||||
|
constructor(@Inject(DOCUMENT) private document: Document) { }
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.nameControl.setValue(this.data.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (!this.form.dirty || this.form.invalid || this.waitForResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.http.post(environment.API_HOST + '/auth/connections', {
|
||||||
|
name: this.nameControl.value,
|
||||||
|
type: this.typeControl.value,
|
||||||
|
client_id: this.clientIdControl.value,
|
||||||
|
grant_type: 'bearer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
|
||||||
|
}
|
||||||
|
}).subscribe(async (d: any) => this.document.location.href = d.data);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
<section [class.twitch]="connection().type == 'twitch'"
|
||||||
|
[class.nightbot]="connection().type == 'nightbot'">
|
||||||
|
{{connection().name}}
|
||||||
|
|
||||||
|
@if (isExpired) {
|
||||||
|
<mat-icon matTooltip="Connection has expired."
|
||||||
|
class="danger">error</mat-icon>
|
||||||
|
} @else if (isExpiringSoon) {
|
||||||
|
<mat-icon matTooltip="Connection is soon going to expire."
|
||||||
|
class="warning">warning</mat-icon>
|
||||||
|
}
|
||||||
|
|
||||||
|
<article class="right">
|
||||||
|
<button mat-button
|
||||||
|
class="neutral"
|
||||||
|
(click)="renew()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Renew
|
||||||
|
</button>
|
||||||
|
<button mat-button
|
||||||
|
class="danger"
|
||||||
|
(click)="delete()">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
@ -0,0 +1,19 @@
|
|||||||
|
section {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch {
|
||||||
|
border-left: 1em solid #6441A5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nightbot {
|
||||||
|
border-left: 1em solid #3D5D9A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0 0.5em;
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ConnectionItemComponent } from './connection-item.component';
|
||||||
|
|
||||||
|
describe('ConnectionItemComponent', () => {
|
||||||
|
let component: ConnectionItemComponent;
|
||||||
|
let fixture: ComponentFixture<ConnectionItemComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ConnectionItemComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConnectionItemComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,60 @@
|
|||||||
|
import { Component, Inject, inject, input } from '@angular/core';
|
||||||
|
import { Connection } from '../../shared/models/connection';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import {MatTooltipModule} from '@angular/material/tooltip';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'connection-item',
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './connection-item.component.html',
|
||||||
|
styleUrl: './connection-item.component.scss'
|
||||||
|
})
|
||||||
|
export class ConnectionItemComponent {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly client = inject(HermesClientService);
|
||||||
|
|
||||||
|
connection = input.required<Connection>();
|
||||||
|
|
||||||
|
constructor(@Inject(DOCUMENT) private document: Document) { }
|
||||||
|
|
||||||
|
delete() {
|
||||||
|
this.client.deleteConnection(this.connection().name);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isExpired() {
|
||||||
|
return moment(this.connection().expires_at).toDate().getTime() < new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isExpiringSoon() {
|
||||||
|
return moment(this.connection().expires_at).toDate().getTime() < moment.now() + moment.duration(7, 'd').asMilliseconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
renew() {
|
||||||
|
const conn = this.connection();
|
||||||
|
this.http.post(environment.API_HOST + '/auth/connections', {
|
||||||
|
name: conn.name,
|
||||||
|
type: conn.type,
|
||||||
|
client_id: conn.client_id,
|
||||||
|
grant_type: conn.grant_type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
|
||||||
|
}
|
||||||
|
}).subscribe(async (d: any) => this.document.location.href = d.data);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
<ul>
|
||||||
|
<li class="header">
|
||||||
|
<mat-form-field appearance="outline"
|
||||||
|
subscriptSizing="dynamic">
|
||||||
|
<mat-label>Name Filter</mat-label>
|
||||||
|
<input matInput
|
||||||
|
placeholder="Filter connections by name"
|
||||||
|
[formControl]="searchControl" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline"
|
||||||
|
subscriptSizing="dynamic">
|
||||||
|
<mat-label>Type Filter</mat-label>
|
||||||
|
<mat-select [formControl]="typeControl">
|
||||||
|
<mat-option value="">All</mat-option>
|
||||||
|
<mat-option value="nightbot">Nightbot</mat-option>
|
||||||
|
<mat-option value="twitch">Twitch</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-icon-button
|
||||||
|
(click)="add()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
@for (connection of connections; track $index) {
|
||||||
|
<li>
|
||||||
|
<connection-item [connection]="connection" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (!connections.length) {
|
||||||
|
@if (searchControl.value) {
|
||||||
|
<p class="notice">No connections matches the filter.</p>
|
||||||
|
} @else {
|
||||||
|
<p class="notice">No connections available.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
@ -0,0 +1,38 @@
|
|||||||
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
background-color: rgb(202, 68, 255);
|
||||||
|
border-radius: 15px;
|
||||||
|
margin: 0 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 500px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@include mat.all-component-densities(-5);
|
||||||
|
|
||||||
|
@include mat.form-field-overrides((
|
||||||
|
outlined-outline-color: rgb(167, 88, 199),
|
||||||
|
outlined-focus-label-text-color: rgb(155, 57, 194),
|
||||||
|
outlined-focus-outline-color: rgb(155, 57, 194),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
background-color: rgb(240, 165, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li.header {
|
||||||
|
background-color: rgb(215, 115, 255);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul .notice {
|
||||||
|
text-align: center;
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ConnectionListComponent } from './connection-list.component';
|
||||||
|
|
||||||
|
describe('ConnectionListComponent', () => {
|
||||||
|
let component: ConnectionListComponent;
|
||||||
|
let fixture: ComponentFixture<ConnectionListComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ConnectionListComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConnectionListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,62 @@
|
|||||||
|
import { Component, inject, Input } from '@angular/core';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { Connection } from '../../shared/models/connection';
|
||||||
|
import { ConnectionItemComponent } from "../connection-item/connection-item.component";
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ConnectionItemEditComponent } from '../connection-item-edit/connection-item-edit.component';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { containsLettersInOrder } from '../../shared/utils/string-compare';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'connection-list',
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ConnectionItemComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './connection-list.component.html',
|
||||||
|
styleUrl: './connection-list.component.scss'
|
||||||
|
})
|
||||||
|
export class ConnectionListComponent {
|
||||||
|
private readonly _dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
private _connections: Connection[] = [];
|
||||||
|
|
||||||
|
readonly searchControl = new FormControl<string>('');
|
||||||
|
readonly typeControl = new FormControl<string>('');
|
||||||
|
|
||||||
|
opened = false;
|
||||||
|
|
||||||
|
|
||||||
|
get connections() {
|
||||||
|
return this._connections.filter(c => containsLettersInOrder(c.name, this.searchControl.value) && (!this.typeControl.value || c.type == this.typeControl.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input({ required: true })
|
||||||
|
set connections(value: Connection[]) {
|
||||||
|
this._connections = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
add() {
|
||||||
|
if (this.opened)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.opened = true;
|
||||||
|
|
||||||
|
const dialogRef = this._dialog.open(ConnectionItemEditComponent, {
|
||||||
|
data: { name: this.searchControl.value },
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((_: any) => this.opened = false);
|
||||||
|
}
|
||||||
|
}
|
12
src/app/connections/connections.module.ts
Normal file
12
src/app/connections/connections.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ConnectionsModule { }
|
@ -0,0 +1,5 @@
|
|||||||
|
<content>
|
||||||
|
<h3>Connections</h3>
|
||||||
|
|
||||||
|
<connection-list [connections]="connections" />
|
||||||
|
</content>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ConnectionsComponent } from './connections.component';
|
||||||
|
|
||||||
|
describe('ConnectionsComponent', () => {
|
||||||
|
let component: ConnectionsComponent;
|
||||||
|
let fixture: ComponentFixture<ConnectionsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ConnectionsComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConnectionsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
41
src/app/connections/connections/connections.component.ts
Normal file
41
src/app/connections/connections/connections.component.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Component, inject, OnDestroy } from '@angular/core';
|
||||||
|
import { Connection } from '../../shared/models/connection';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { ConnectionListComponent } from "../connection-list/connection-list.component";
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { ConnectionService } from '../../shared/services/connection.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'connections',
|
||||||
|
imports: [ConnectionListComponent],
|
||||||
|
templateUrl: './connections.component.html',
|
||||||
|
styleUrl: './connections.component.scss'
|
||||||
|
})
|
||||||
|
export class ConnectionsComponent implements OnDestroy {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly connectionService = inject(ConnectionService);
|
||||||
|
subscriptions: (Subscription | undefined)[] = [];
|
||||||
|
connections: Connection[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.route.data.subscribe(payload => {
|
||||||
|
this.connections = payload['connections'] ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriptions.push(this.connectionService.delete$?.subscribe(d => {
|
||||||
|
if (d.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectionService.fetch().subscribe(connections => this.connections = connections);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
for (let subscription of this.subscriptions) {
|
||||||
|
if (subscription)
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
src/app/groups/group-dropdown/group-dropdown.component.html
Normal file
26
src/app/groups/group-dropdown/group-dropdown.component.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Group</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="text"
|
||||||
|
placeholder="Pick a group"
|
||||||
|
aria-label="group"
|
||||||
|
[formControl]="formControl"
|
||||||
|
[matAutocomplete]="auto"
|
||||||
|
[readonly]="!!groupDisabled"
|
||||||
|
(blur)="blur()"
|
||||||
|
(input)="input()">
|
||||||
|
<mat-autocomplete #auto="matAutocomplete"
|
||||||
|
[displayWith]="displayFn"
|
||||||
|
(optionSelected)="select($event.option.value)">
|
||||||
|
@for (group of filteredGroups; track group.id) {
|
||||||
|
<mat-option [value]="group">{{group.name}}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-autocomplete>
|
||||||
|
@if (!search && formControl.invalid && (formControl.dirty || formControl.touched)) {
|
||||||
|
@for (error of errorMessageKeys; track $index) {
|
||||||
|
@if (formControl.hasError(error)) {
|
||||||
|
<small class="error">{{errorMessages[error]}}</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { GroupDropdownComponent } from './group-dropdown.component';
|
||||||
|
|
||||||
|
describe('GroupDropdownComponent', () => {
|
||||||
|
let component: GroupDropdownComponent;
|
||||||
|
let fixture: ComponentFixture<GroupDropdownComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [GroupDropdownComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GroupDropdownComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
99
src/app/groups/group-dropdown/group-dropdown.component.ts
Normal file
99
src/app/groups/group-dropdown/group-dropdown.component.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { Component, EventEmitter, inject, Input, OnInit, Output } from '@angular/core';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { Group } from '../../shared/models/group';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'group-dropdown',
|
||||||
|
imports: [
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './group-dropdown.component.html',
|
||||||
|
styleUrl: './group-dropdown.component.scss'
|
||||||
|
})
|
||||||
|
export class GroupDropdownComponent implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
@Input() formControl = new FormControl<Group | string | undefined>(undefined);
|
||||||
|
@Input() errorMessages: { [errorKey: string]: string } = {};
|
||||||
|
@Input({ required: true }) groups: Group[] = [];
|
||||||
|
@Input() group: string | undefined;
|
||||||
|
@Input() groupDisabled: boolean | undefined;
|
||||||
|
@Input() search: boolean = false;
|
||||||
|
@Output() readonly groupChange = new EventEmitter<string>();
|
||||||
|
|
||||||
|
errorMessageKeys: string[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.route.data.subscribe(data => {
|
||||||
|
if (!data['groups'])
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.groups = data['groups'];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.groupDisabled)
|
||||||
|
this.formControl.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.errorMessageKeys = Object.keys(this.errorMessages);
|
||||||
|
|
||||||
|
if (!this.group)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const group = this.groups.find(r => r.id == this.group);
|
||||||
|
this.formControl.setValue(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredGroups() {
|
||||||
|
const value = this.formControl.value;
|
||||||
|
if (typeof value == 'string') {
|
||||||
|
return this.groups.filter(r => r.name.toLowerCase().includes(value.toLowerCase()));
|
||||||
|
}
|
||||||
|
return this.groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
select(event: Group) {
|
||||||
|
this.groupChange.emit(event.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
input() {
|
||||||
|
if (this.search && typeof this.formControl.value == 'string') {
|
||||||
|
this.groupChange.emit(this.formControl.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blur() {
|
||||||
|
if (!this.search && typeof this.formControl.value == 'string') {
|
||||||
|
const name = this.formControl.value;
|
||||||
|
const nameLower = name.toLowerCase();
|
||||||
|
let newValue: Group | undefined = undefined;
|
||||||
|
const insenstiveGroups = this.filteredGroups.filter(a => a.name.toLowerCase() == nameLower);
|
||||||
|
if (insenstiveGroups.length > 1) {
|
||||||
|
const sensitiveGroup = insenstiveGroups.find(a => a.name == name);
|
||||||
|
newValue = sensitiveGroup ?? undefined;
|
||||||
|
} else if (insenstiveGroups.length == 1) {
|
||||||
|
newValue = insenstiveGroups[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValue) {
|
||||||
|
this.formControl.setValue(newValue);
|
||||||
|
//this.groupChange.emit(newValue.name);
|
||||||
|
} else if (!newValue)
|
||||||
|
this.formControl.setValue(undefined);
|
||||||
|
//this.groupChange.emit(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayFn(value: Group) {
|
||||||
|
return value?.name;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
<mat-card>
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title-group>
|
||||||
|
<mat-card-title>Edit Group</mat-card-title>
|
||||||
|
</mat-card-title-group>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Group Name</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="text"
|
||||||
|
[formControl]="nameForm"
|
||||||
|
[disabled]="isSpecial" />
|
||||||
|
@if (nameForm.invalid && (nameForm.dirty || nameForm.touched)) {
|
||||||
|
@if (nameForm.hasError('required')) {
|
||||||
|
<small class="error">This field is required.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>TTS Priority</mat-label>
|
||||||
|
<input matInput
|
||||||
|
type="number"
|
||||||
|
[formControl]="priorityForm" />
|
||||||
|
@if (priorityForm.invalid && (priorityForm.dirty || priorityForm.touched)) {
|
||||||
|
@if (priorityForm.hasError('required')) {
|
||||||
|
<small class="error">This field is required.</small>
|
||||||
|
}
|
||||||
|
@if (priorityForm.hasError('min')) {
|
||||||
|
<small class="error">This field must be greater than -2147483649.</small>
|
||||||
|
}
|
||||||
|
@if (priorityForm.hasError('max')) {
|
||||||
|
<small class="error">This field must be smaller than 2147483648.</small>
|
||||||
|
}
|
||||||
|
@if (priorityForm.hasError('integer') && !priorityForm.hasError('min') && !priorityForm.hasError('max')) {
|
||||||
|
<small class="error">This field must be an integer.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</mat-card-content>
|
||||||
|
<mat-card-actions>
|
||||||
|
<button mat-button
|
||||||
|
class="neutral"
|
||||||
|
[disabled]="waitForResponse"
|
||||||
|
(click)="cancel()">
|
||||||
|
<mat-icon>cancel</mat-icon>Cancel
|
||||||
|
</button>
|
||||||
|
<button mat-button
|
||||||
|
class="confirm"
|
||||||
|
[disabled]="waitForResponse || formGroup.invalid"
|
||||||
|
(click)="add()">
|
||||||
|
<mat-icon>add</mat-icon>Add
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
|
||||||
|
@if (responseError) {
|
||||||
|
<mat-card-footer>
|
||||||
|
<small class="error below">{{responseError}}</small>
|
||||||
|
</mat-card-footer>
|
||||||
|
}
|
||||||
|
</mat-card>
|
@ -0,0 +1,8 @@
|
|||||||
|
.mat-mdc-form-field {
|
||||||
|
display: block;
|
||||||
|
margin: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-actions {
|
||||||
|
align-self: center;
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { GroupItemEditComponent } from './group-item-edit.component';
|
||||||
|
|
||||||
|
describe('GroupItemEditComponent', () => {
|
||||||
|
let component: GroupItemEditComponent;
|
||||||
|
let fixture: ComponentFixture<GroupItemEditComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [GroupItemEditComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GroupItemEditComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
76
src/app/groups/group-item-edit/group-item-edit.component.ts
Normal file
76
src/app/groups/group-item-edit/group-item-edit.component.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Component, inject, Input, OnInit } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { Group } from '../../shared/models/group';
|
||||||
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import { integerValidator } from '../../shared/validators/integer';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'group-item-edit',
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatIconModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './group-item-edit.component.html',
|
||||||
|
styleUrl: './group-item-edit.component.scss'
|
||||||
|
})
|
||||||
|
export class GroupItemEditComponent implements OnInit {
|
||||||
|
private readonly _client = inject(HermesClientService);
|
||||||
|
private readonly _dialogRef = inject(MatDialogRef<GroupItemEditComponent>);
|
||||||
|
private readonly _data = inject(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
group: Group = { id: '', user_id: '', name: '', priority: 0 };
|
||||||
|
isSpecial: boolean = false;
|
||||||
|
responseError: string | undefined;
|
||||||
|
waitForResponse: boolean = false;
|
||||||
|
|
||||||
|
nameForm = new FormControl('', [Validators.required]);
|
||||||
|
priorityForm = new FormControl(0, [Validators.required, Validators.min(-2147483648), Validators.max(2147483647), integerValidator]);
|
||||||
|
formGroup = new FormGroup({
|
||||||
|
name: this.nameForm,
|
||||||
|
priority: this.priorityForm,
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.group = this._data.group;
|
||||||
|
this.isSpecial = this._data.isSpecial;
|
||||||
|
this.nameForm.setValue(this.group.name);
|
||||||
|
if (this.isSpecial)
|
||||||
|
this.nameForm.disable();
|
||||||
|
this.priorityForm.setValue(this.group.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
add() {
|
||||||
|
if (!this.formGroup.dirty || this.formGroup.invalid || this.waitForResponse)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.waitForResponse = true;
|
||||||
|
this.responseError = undefined;
|
||||||
|
this._client.first((d: any) => d.op == 4 && d.d.request.type == 'create_group' && d.d.data.name == this.nameForm.value)
|
||||||
|
.subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
if (d.d.error) {
|
||||||
|
this.responseError = d.d.error;
|
||||||
|
} else {
|
||||||
|
this._dialogRef.close(d.d.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => this.responseError = 'Something went wrong.',
|
||||||
|
complete: () => this.waitForResponse = false,
|
||||||
|
});
|
||||||
|
this._client.createGroup(this.nameForm.value!, this.priorityForm.value!);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this._dialogRef.close();
|
||||||
|
}
|
||||||
|
}
|
33
src/app/groups/group-item/group-item.component.html
Normal file
33
src/app/groups/group-item/group-item.component.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<article>
|
||||||
|
<section class="title">{{group().name}}
|
||||||
|
@if (special) {
|
||||||
|
<small class="tag">auto-generated</small>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<section class="">
|
||||||
|
{{group().priority}}
|
||||||
|
<small class="muted block">priority</small>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
@if (special) {
|
||||||
|
<p class="muted">Unknown</p>
|
||||||
|
} @else {
|
||||||
|
{{chatters().length}}
|
||||||
|
<small class="muted block">user{{chatters().length == 1 ? '' : 's'}}</small>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
{{permissions().length}}
|
||||||
|
<small class="muted block">permission{{permissions().length == 1 ? '' : 's'}}</small>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
{{policies().length}}
|
||||||
|
<small class="muted block">polic{{policies().length == 1 ? 'y' : 'ies'}}</small>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<button mat-button
|
||||||
|
(click)="router.navigate([link])">
|
||||||
|
<mat-icon>pageview</mat-icon>View
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</article>
|
49
src/app/groups/group-item/group-item.component.scss
Normal file
49
src/app/groups/group-item/group-item.component.scss
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
article {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: grey solid 1px;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
|
||||||
|
& > :first-child {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :last-child {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :not(:first-child) {
|
||||||
|
text-align: center;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.5em;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 11px;
|
||||||
|
background-color: white;
|
||||||
|
color: rgb(204, 51, 204);
|
||||||
|
padding: 4px;
|
||||||
|
margin: 0 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: grey;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
23
src/app/groups/group-item/group-item.component.spec.ts
Normal file
23
src/app/groups/group-item/group-item.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { GroupItemComponent } from './group-item.component';
|
||||||
|
|
||||||
|
describe('GroupItemComponent', () => {
|
||||||
|
let component: GroupItemComponent;
|
||||||
|
let fixture: ComponentFixture<GroupItemComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [GroupItemComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GroupItemComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
36
src/app/groups/group-item/group-item.component.ts
Normal file
36
src/app/groups/group-item/group-item.component.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Component, inject, input, OnInit } from '@angular/core';
|
||||||
|
import { Group } from '../../shared/models/group';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { Policy } from '../../shared/models/policy';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { GroupChatter } from '../../shared/models/group-chatter';
|
||||||
|
import { SpecialGroups } from '../../shared/utils/groups';
|
||||||
|
import { Permission } from '../../shared/models/permission';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'group-item',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatIconModule,
|
||||||
|
],
|
||||||
|
templateUrl: './group-item.component.html',
|
||||||
|
styleUrl: './group-item.component.scss'
|
||||||
|
})
|
||||||
|
export class GroupItemComponent implements OnInit {
|
||||||
|
readonly router = inject(Router);
|
||||||
|
group = input.required<Group>();
|
||||||
|
chatters = input.required<GroupChatter[]>();
|
||||||
|
permissions = input.required<Permission[]>();
|
||||||
|
policies = input.required<Policy[]>();
|
||||||
|
link: string = '';
|
||||||
|
special: boolean = true;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.special = SpecialGroups.includes(this.group().name);
|
||||||
|
this.link = 'groups/' + this.group().id;
|
||||||
|
}
|
||||||
|
}
|
10
src/app/groups/group-list/group-list.component.html
Normal file
10
src/app/groups/group-list/group-list.component.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<ul>
|
||||||
|
@for (group of groups; track group.id) {
|
||||||
|
<li>
|
||||||
|
<group-item [group]="group"
|
||||||
|
[chatters]="getChattersByGroup(group.id)"
|
||||||
|
[permissions]="getPermissionsByGroup(group.id)"
|
||||||
|
[policies]="getPoliciesByGroup(group.id)" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
9
src/app/groups/group-list/group-list.component.scss
Normal file
9
src/app/groups/group-list/group-list.component.scss
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1em;
|
||||||
|
}
|
23
src/app/groups/group-list/group-list.component.spec.ts
Normal file
23
src/app/groups/group-list/group-list.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { GroupListComponent } from './group-list.component';
|
||||||
|
|
||||||
|
describe('GroupListComponent', () => {
|
||||||
|
let component: GroupListComponent;
|
||||||
|
let fixture: ComponentFixture<GroupListComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [GroupListComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GroupListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
47
src/app/groups/group-list/group-list.component.ts
Normal file
47
src/app/groups/group-list/group-list.component.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Component, input, Input } from '@angular/core';
|
||||||
|
import { Group } from '../../shared/models/group';
|
||||||
|
import { GroupItemComponent } from "../group-item/group-item.component";
|
||||||
|
import { Policy } from '../../shared/models/policy';
|
||||||
|
import { GroupChatter } from '../../shared/models/group-chatter';
|
||||||
|
import { Permission } from '../../shared/models/permission';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'group-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [GroupItemComponent],
|
||||||
|
templateUrl: './group-list.component.html',
|
||||||
|
styleUrl: './group-list.component.scss'
|
||||||
|
})
|
||||||
|
export class GroupListComponent {
|
||||||
|
_groups = input.required<Group[]>({ alias: 'groups' });
|
||||||
|
chatters = input.required<GroupChatter[]>();
|
||||||
|
permissions = input.required<Permission[]>();
|
||||||
|
policies = input.required<Policy[]>();
|
||||||
|
private _filter: (item: Group) => boolean = _ => true;
|
||||||
|
|
||||||
|
|
||||||
|
get filter(): (group: Group) => boolean {
|
||||||
|
return this._filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input({ alias: 'filter', required: false })
|
||||||
|
set filter(value: (item: Group) => boolean) {
|
||||||
|
this._filter = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get groups() {
|
||||||
|
return this._groups().filter(g => this._filter(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
getChattersByGroup(groupId: string) {
|
||||||
|
return this.chatters().filter(c => c.group_id == groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPermissionsByGroup(groupId: string) {
|
||||||
|
return this.permissions().filter(c => c.group_id == groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPoliciesByGroup(groupId: string) {
|
||||||
|
return this.policies().filter(c => c.group_id == groupId);
|
||||||
|
}
|
||||||
|
}
|
67
src/app/groups/group-page/group-page.component.html
Normal file
67
src/app/groups/group-page/group-page.component.html
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<div>
|
||||||
|
<h2>{{group?.name}}</h2>
|
||||||
|
|
||||||
|
@if (!isSpecialGroup) {
|
||||||
|
<mat-expansion-panel>
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title>Users</mat-panel-title>
|
||||||
|
<mat-panel-description class="muted">
|
||||||
|
{{chatters.length}} user{{chatters.length == 1 ? '' : 's'}}
|
||||||
|
</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<twitch-user-list [twitchUsers]="chatters"
|
||||||
|
[group]="group" />
|
||||||
|
</mat-expansion-panel>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-expansion-panel>
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title>Permissions</mat-panel-title>
|
||||||
|
<mat-panel-description class="muted">
|
||||||
|
{{permissions.length}} permission{{permissions.length == 1 ? '' : 's'}}
|
||||||
|
</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<permission-list [permissions]="permissions"
|
||||||
|
[groups]="groups"
|
||||||
|
[group]="group" />
|
||||||
|
</mat-expansion-panel>
|
||||||
|
|
||||||
|
<mat-expansion-panel>
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title>Policies</mat-panel-title>
|
||||||
|
<mat-panel-description class="muted">
|
||||||
|
{{policies.length}} polic{{policies.length == 1 ? 'y' : 'ies'}}
|
||||||
|
</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<policy-add-button class="add"
|
||||||
|
[groups]="groups"
|
||||||
|
[policies]="policies"
|
||||||
|
[group]="group?.id" />
|
||||||
|
<policy-table [policies]="policies"
|
||||||
|
[groups]="groups"/>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
|
||||||
|
<mat-expansion-panel>
|
||||||
|
<mat-expansion-panel-header>
|
||||||
|
<mat-panel-title class="danger">Danger Zone</mat-panel-title>
|
||||||
|
<mat-panel-description class="muted">
|
||||||
|
Dangerous actions
|
||||||
|
</mat-panel-description>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
<div class="content">
|
||||||
|
<section>
|
||||||
|
<article class="left">
|
||||||
|
<h4>Deletion</h4>
|
||||||
|
<p>Deleting this group will delete everything that is part of it, including policies and permissions.</p>
|
||||||
|
</article>
|
||||||
|
<article class="right">
|
||||||
|
<button mat-raised-button
|
||||||
|
class="danger"
|
||||||
|
(click)="delete()">
|
||||||
|
<mat-icon>delete</mat-icon>Delete this group.
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</mat-expansion-panel>
|
||||||
|
</div>
|
21
src/app/groups/group-page/group-page.component.scss
Normal file
21
src/app/groups/group-page/group-page.component.scss
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
.mat-expansion-panel~.mat-expansion-panel {
|
||||||
|
margin-top: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
justify-content: space-around;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: grey;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
float: right;
|
||||||
|
}
|
23
src/app/groups/group-page/group-page.component.spec.ts
Normal file
23
src/app/groups/group-page/group-page.component.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { GroupPageComponent } from './group-page.component';
|
||||||
|
|
||||||
|
describe('GroupPageComponent', () => {
|
||||||
|
let component: GroupPageComponent;
|
||||||
|
let fixture: ComponentFixture<GroupPageComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [GroupPageComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GroupPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
168
src/app/groups/group-page/group-page.component.ts
Normal file
168
src/app/groups/group-page/group-page.component.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { Component, inject, OnDestroy } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Group } from '../../shared/models/group';
|
||||||
|
import { Policy } from '../../shared/models/policy';
|
||||||
|
import { MatExpansionModule } from '@angular/material/expansion';
|
||||||
|
import { PoliciesModule } from '../../policies/policies.module';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { PolicyTableComponent } from "../../policies/policy-table/policy-table.component";
|
||||||
|
import { PolicyAddButtonComponent } from '../../policies/policy-add-button/policy-add-button.component';
|
||||||
|
import { HermesClientService } from '../../hermes-client.service';
|
||||||
|
import { GroupChatter } from '../../shared/models/group-chatter';
|
||||||
|
import { TwitchUsersModule } from "../../twitch-users/twitch-users.module";
|
||||||
|
import { SpecialGroups } from '../../shared/utils/groups';
|
||||||
|
import { PermissionListComponent } from "../../permissions/permission-list/permission-list.component";
|
||||||
|
import { Permission } from '../../shared/models/permission';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { PermissionService } from '../../shared/services/permission.service';
|
||||||
|
import GroupService from '../../shared/services/group.service';
|
||||||
|
import PolicyService from '../../shared/services/policy.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatExpansionModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
PoliciesModule,
|
||||||
|
PolicyAddButtonComponent,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
PolicyTableComponent,
|
||||||
|
PolicyTableComponent,
|
||||||
|
TwitchUsersModule,
|
||||||
|
PermissionListComponent
|
||||||
|
],
|
||||||
|
templateUrl: './group-page.component.html',
|
||||||
|
styleUrl: './group-page.component.scss'
|
||||||
|
})
|
||||||
|
export class GroupPageComponent implements OnDestroy {
|
||||||
|
private readonly _router = inject(Router);
|
||||||
|
private readonly _route = inject(ActivatedRoute);
|
||||||
|
private readonly _groupService = inject(GroupService);
|
||||||
|
private readonly _permissionService = inject(PermissionService);
|
||||||
|
private readonly _policyService = inject(PolicyService);
|
||||||
|
private readonly _client = inject(HermesClientService);
|
||||||
|
private _group: Group | undefined;
|
||||||
|
private _chatters: GroupChatter[];
|
||||||
|
private _policies: Policy[];
|
||||||
|
private _permissions: Permission[];
|
||||||
|
|
||||||
|
isSpecialGroup: boolean;
|
||||||
|
_groups: Group[];
|
||||||
|
|
||||||
|
private readonly subscriptions: (Subscription | undefined)[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.isSpecialGroup = false
|
||||||
|
this._groups = [];
|
||||||
|
this._chatters = [];
|
||||||
|
this._permissions = [];
|
||||||
|
this._policies = [];
|
||||||
|
|
||||||
|
this._route.params.subscribe((params: any) => {
|
||||||
|
// Fetch the group id from the query params.
|
||||||
|
const group_id = params['id'];
|
||||||
|
|
||||||
|
this._route.data.subscribe(async (data: any) => {
|
||||||
|
this._groups = data['groups'];
|
||||||
|
const group = this.groups.find((g: Group) => g.id == group_id);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
await this._router.navigate(['groups']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._group = group;
|
||||||
|
this.isSpecialGroup = SpecialGroups.includes(this.group!.name);
|
||||||
|
this._chatters = data['chatters'];
|
||||||
|
this._permissions = data['permissions'];
|
||||||
|
this._policies = data['policies'];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriptions.push(this._permissionService.delete$?.subscribe(d => {
|
||||||
|
if (d.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._permissionService.fetch().subscribe(permissions => this._permissions = permissions);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.subscriptions.push(this._groupService.deleteGroup$?.subscribe(d => {
|
||||||
|
if (d.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._groupService.fetch().subscribe(data => this._groups = data.groups);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.subscriptions.push(this._groupService.deleteChatter$?.subscribe(d => {
|
||||||
|
if (d.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._groupService.fetch().subscribe(data => this._chatters = data.chatters);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.subscriptions.push(this._policyService.delete$?.subscribe(d => {
|
||||||
|
if (d.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._policyService.fetch().subscribe(policies => this._policies = policies);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.subscriptions) {
|
||||||
|
for (let subscription of this.subscriptions) {
|
||||||
|
if (subscription)
|
||||||
|
subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get group() {
|
||||||
|
return this._group;
|
||||||
|
}
|
||||||
|
|
||||||
|
get groups() {
|
||||||
|
return this._groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
get chatters() {
|
||||||
|
if (!this._group) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this._chatters.filter((c: GroupChatter) => c.group_id == this._group!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get permissions() {
|
||||||
|
if (!this._group) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this._permissions.filter((p: Permission) => p.group_id == this._group!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get policies() {
|
||||||
|
if (!this._group) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this._policies.filter((p: Policy) => p.group_id == this._group!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete() {
|
||||||
|
if (!this.group)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this._client.first(d => d.op == 4 && d.d.request.type == 'delete_group' && d.d.request.data.id == this.group!.id)
|
||||||
|
.subscribe(async () => await this._router.navigate(['groups']));
|
||||||
|
this._client.deleteGroup(this.group.id);
|
||||||
|
}
|
||||||
|
}
|
18
src/app/groups/groups.module.ts
Normal file
18
src/app/groups/groups.module.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { GroupDropdownComponent } from './group-dropdown/group-dropdown.component';
|
||||||
|
import { GroupsComponent } from './groups/groups.component';
|
||||||
|
import { GroupListComponent } from './group-list/group-list.component';
|
||||||
|
import { GroupItemComponent } from './group-item/group-item.component';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
GroupDropdownComponent,
|
||||||
|
GroupListComponent,
|
||||||
|
GroupItemComponent,
|
||||||
|
GroupsComponent,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GroupsModule { }
|
20
src/app/groups/groups/groups.component.html
Normal file
20
src/app/groups/groups/groups.component.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<button mat-button
|
||||||
|
[mat-menu-trigger-for]="menu">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Add a group
|
||||||
|
</button>
|
||||||
|
<mat-menu #menu="matMenu">
|
||||||
|
<button mat-menu-item
|
||||||
|
(click)="openDialog('')">Custom Group</button>
|
||||||
|
@for (group of specialGroups; track $index) {
|
||||||
|
@if (!exists(group)) {
|
||||||
|
<button mat-menu-item
|
||||||
|
(click)="openDialog(group)">{{group[0].toUpperCase() + group.substring(1)}} Group</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-menu>
|
||||||
|
<group-list class="groups"
|
||||||
|
[groups]="groups"
|
||||||
|
[chatters]="chatters"
|
||||||
|
[permissions]="permissions"
|
||||||
|
[policies]="policies" />
|
7
src/app/groups/groups/groups.component.scss
Normal file
7
src/app/groups/groups/groups.component.scss
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
color: red;
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user