From d595c3500ee76a0fcddd5a36daffe390ce54d0fa Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 8 Jan 2025 21:50:18 +0000 Subject: [PATCH] Added pages to see, create, modify & delete redeemable actions. User card top right with disconnect & log out. Code clean up. --- .../action-item-edit.component.html | 94 ++++++ .../action-item-edit.component.scss | 30 ++ .../action-item-edit.component.spec.ts | 23 ++ .../action-item-edit.component.ts | 267 +++++++++++++++ .../action-list/action-list.component.html | 11 + .../action-list/action-list.component.scss | 56 ++++ .../action-list/action-list.component.spec.ts | 23 ++ .../action-list/action-list.component.ts | 66 ++++ src/app/actions/actions.module.ts | 17 + .../actions/actions/actions.component.html | 31 ++ .../actions/actions/actions.component.scss | 23 ++ .../actions/actions/actions.component.spec.ts | 23 ++ src/app/actions/actions/actions.component.ts | 93 ++++++ src/app/app.config.ts | 1 - src/app/app.routes.ts | 4 +- src/app/auth/auth.module.ts | 6 +- .../impersonation.component.html | 28 +- .../impersonation.component.scss | 6 + .../impersonation/impersonation.component.ts | 1 - .../auth/tts-login/tts-login.component.html | 37 ++- .../auth/tts-login/tts-login.component.scss | 19 +- src/app/auth/tts-login/tts-login.component.ts | 88 +++-- .../auth/user-card/user-card.component.html | 20 ++ .../auth/user-card/user-card.component.scss | 23 ++ .../user-card/user-card.component.spec.ts | 23 ++ src/app/auth/user-card/user-card.component.ts | 26 ++ src/app/hermes-client.service.ts | 81 ++++- src/app/hermes-socket.service.ts | 2 +- src/app/navigation/navigation.component.html | 2 +- src/app/navigation/navigation.component.scss | 1 - src/app/navigation/navigation.component.ts | 4 +- .../policy-add-form.component.html | 2 +- .../policy-table/policy-table.component.ts | 314 +++++++++--------- src/app/shared/models/filter.ts | 3 + src/app/shared/models/redeemable_action.ts | 6 + .../api/api-authentication.service.ts | 5 + .../shared/validators/item-exists-in-array.ts | 14 + .../filter-item-edit.component.ts | 6 +- .../filter-item/filter-item.component.ts | 1 - .../tts-filters/filters/filters.component.ts | 7 - .../twitch-auth-callback.component.ts | 62 ++-- 41 files changed, 1228 insertions(+), 321 deletions(-) create mode 100644 src/app/actions/action-item-edit/action-item-edit.component.html create mode 100644 src/app/actions/action-item-edit/action-item-edit.component.scss create mode 100644 src/app/actions/action-item-edit/action-item-edit.component.spec.ts create mode 100644 src/app/actions/action-item-edit/action-item-edit.component.ts create mode 100644 src/app/actions/action-list/action-list.component.html create mode 100644 src/app/actions/action-list/action-list.component.scss create mode 100644 src/app/actions/action-list/action-list.component.spec.ts create mode 100644 src/app/actions/action-list/action-list.component.ts create mode 100644 src/app/actions/actions.module.ts create mode 100644 src/app/actions/actions/actions.component.html create mode 100644 src/app/actions/actions/actions.component.scss create mode 100644 src/app/actions/actions/actions.component.spec.ts create mode 100644 src/app/actions/actions/actions.component.ts create mode 100644 src/app/auth/user-card/user-card.component.html create mode 100644 src/app/auth/user-card/user-card.component.scss create mode 100644 src/app/auth/user-card/user-card.component.spec.ts create mode 100644 src/app/auth/user-card/user-card.component.ts create mode 100644 src/app/shared/models/redeemable_action.ts create mode 100644 src/app/shared/validators/item-exists-in-array.ts diff --git a/src/app/actions/action-item-edit/action-item-edit.component.html b/src/app/actions/action-item-edit/action-item-edit.component.html new file mode 100644 index 0000000..fda87e9 --- /dev/null +++ b/src/app/actions/action-item-edit/action-item-edit.component.html @@ -0,0 +1,94 @@ + + + + + {{isNew ? "New Action" : previousName}} + {{isNew ? 'Creating a new action' : 'Modifying an existing action'}} + + + + +
+
+ + Redeemable Action Name + + @if (isNew && formGroup.get('name')?.invalid && (formGroup.get('name')?.dirty || + formGroup.get('name')?.touched)) { + @if (formGroup.get('name')?.hasError('required')) { + The name is required. + } + @if (formGroup.get('name')?.hasError('itemExistsInArray')) { + The name is already in use. + } + } + +
+
+ + Type + + @for (type of actionTypes; track $index) { + {{type}} + } + + @if (isNew && formGroup.get('type')?.invalid && (formGroup.get('type')?.dirty || + formGroup.get('type')?.touched)) { + @if (formGroup.get('type')?.hasError('required')) { + The type is required. + } + } + + +
+
+ + @if (actionEntries.hasOwnProperty(action.type)) { +
+ @for (field of actionEntries[action.type]; track $index) { +
+ @if (field.type == 'text') { + + {{field.label}} + + @if (field.control.invalid && (field.control.dirty || field.control.touched)) { + @if (field.control.hasError('required')) { + This field is required. + } + @if (field.control.hasError('minlength')) { + The value needs to be longer. + } + } + + } + @else if (field.type == 'number') { + + {{field.label}} + + @if (field.control.invalid && (field.control.dirty || field.control.touched)) { + @if (field.control.hasError('required')) { + This field is required. + } + @if (field.control.hasError('min')) { + The value must be higher. + } + } + + } + +
+ } +
+ } +
+ + + @if (!isNew) { + + } + + + +
+ \ No newline at end of file diff --git a/src/app/actions/action-item-edit/action-item-edit.component.scss b/src/app/actions/action-item-edit/action-item-edit.component.scss new file mode 100644 index 0000000..25b7ce5 --- /dev/null +++ b/src/app/actions/action-item-edit/action-item-edit.component.scss @@ -0,0 +1,30 @@ +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-auto-flow: row dense; + grid-gap: 0 1em; +} + +.item { + margin: 0; +} + +.error { + display: block; + color: #ba1a1a; +} + +.actions { + display: flex; + flex-direction: row; + justify-content: center; +} + +.delete { + background-color: #ea5151; + color: #ba1a1a; +} + +.mdc-button ~ .mdc-button { + margin-left: 1em; +} \ No newline at end of file diff --git a/src/app/actions/action-item-edit/action-item-edit.component.spec.ts b/src/app/actions/action-item-edit/action-item-edit.component.spec.ts new file mode 100644 index 0000000..2ce4174 --- /dev/null +++ b/src/app/actions/action-item-edit/action-item-edit.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ActionItemEditComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ActionItemEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/actions/action-item-edit/action-item-edit.component.ts b/src/app/actions/action-item-edit/action-item-edit.component.ts new file mode 100644 index 0000000..5ba0feb --- /dev/null +++ b/src/app/actions/action-item-edit/action-item-edit.component.ts @@ -0,0 +1,267 @@ +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'; + +@Component({ + selector: 'action-item-edit', + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatCardModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule + ], + templateUrl: './action-item-edit.component.html', + styleUrl: './action-item-edit.component.scss' +}) +export class ActionItemEditComponent implements OnInit { + readonly client = inject(HermesClientService); + readonly dialogRef = inject(MatDialogRef); + readonly data = inject<{ action: RedeemableAction, actions:RedeemableAction[] }>(MAT_DIALOG_DATA); + 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; + + 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) + return; + + this.client.deleteRedeemableAction(action.name); + this.dialogRef.close(); + } + + save(): void { + if (this.formGroup.invalid) { + return; + } + + const fields = this.actionEntries[this.action.type]; + if (fields.some(f => f.control.invalid)) { + 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(); + } + + if (!(this.action.type in this.actionEntries)) { + return; + } + + this.dialogRef.close(this.action); + } +} diff --git a/src/app/actions/action-list/action-list.component.html b/src/app/actions/action-list/action-list.component.html new file mode 100644 index 0000000..35f182a --- /dev/null +++ b/src/app/actions/action-list/action-list.component.html @@ -0,0 +1,11 @@ +
+ @for (action of actions; track $index) { + + } + +
\ No newline at end of file diff --git a/src/app/actions/action-list/action-list.component.scss b/src/app/actions/action-list/action-list.component.scss new file mode 100644 index 0000000..fe1d043 --- /dev/null +++ b/src/app/actions/action-list/action-list.component.scss @@ -0,0 +1,56 @@ +main { + display: grid; + grid-template-columns: repeat(1, 1fr); + + @media (min-width:1200px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width:1650px) { + grid-template-columns: repeat(3, 1fr); + } + + @media (min-width:2200px) { + grid-template-columns: repeat(4, 1fr); + } + + grid-auto-flow: row dense; + grid-gap: 1rem; + justify-content: center; + text-align: center; + background-color: #fafafa; + width: 80%; + justify-self: center; + + & .container { + border-color: grey; + border-radius: 20px; + border: 1px solid grey; + padding: 1em; + cursor: pointer; + background-color: white; + + & span { + display: block; + } + + & .title { + font-size: medium; + } + + & .subtitle { + font-size: smaller; + color: lightgrey; + } + } +} + +.item { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + & article:first-child { + flex: 1; + } +} \ No newline at end of file diff --git a/src/app/actions/action-list/action-list.component.spec.ts b/src/app/actions/action-list/action-list.component.spec.ts new file mode 100644 index 0000000..79b2016 --- /dev/null +++ b/src/app/actions/action-list/action-list.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ActionListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ActionListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/actions/action-list/action-list.component.ts b/src/app/actions/action-list/action-list.component.ts new file mode 100644 index 0000000..e81c425 --- /dev/null +++ b/src/app/actions/action-list/action-list.component.ts @@ -0,0 +1,66 @@ +import { Component, EventEmitter, inject, Input, Output } 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'; + +@Component({ + selector: 'action-list', + standalone: true, + imports: [MatButtonModule, MatFormFieldModule, MatIconModule, MatListModule], + templateUrl: './action-list.component.html', + styleUrl: './action-list.component.scss' +}) +export class ActionListComponent { + @Input() actions: RedeemableAction[] = [] + @Output() actionsChange = new EventEmitter(); + readonly dialog = inject(MatDialog); + readonly client = inject(HermesClientService); + opened = false; + + create(): void { + this.openDialog({ user_id: '', name: '', type: '', data: {} }); + } + + modify(action: RedeemableAction): void { + this.openDialog(action); + } + + private openDialog(action: RedeemableAction): void { + if (this.opened) + return; + + this.opened = true; + + const dialogRef = this.dialog.open(ActionItemEditComponent, { + data: { action: {user_id: action.user_id, name: action.name, type: action.type, data: action.data }, actions: this.actions }, + }); + const isNewAction = action.name.length <= 0; + const requestType = isNewAction ? 'create_redeemable_action' : 'update_redeemable_action'; + + dialogRef.afterClosed().subscribe((result: RedeemableAction) => { + this.opened = false; + if (!result) + return; + + this.client.first((d: any) => d.op == 4 && d.d.request.type == requestType && d.d.data.name == result.name) + ?.subscribe(_ => { + if (isNewAction) { + this.actionsChange.emit(result); + } else { + action.type = result.type; + action.data = result.data; + } + }); + + if (isNewAction) + this.client.createRedeemableAction(result.name, result.type, result.data); + else + this.client.updateRedeemableAction(result.name, result.type, result.data); + }); + } +} diff --git a/src/app/actions/actions.module.ts b/src/app/actions/actions.module.ts new file mode 100644 index 0000000..3acfc2f --- /dev/null +++ b/src/app/actions/actions.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActionsComponent } from './actions/actions.component'; +import { ActionListComponent } from './action-list/action-list.component'; +import { ActionItemComponent } from './action-item/action-item.component'; + + + +@NgModule({ + declarations: [], + imports: [ + ActionsComponent, + ActionListComponent, + ActionItemComponent, + ] +}) +export class ActionsModule { } diff --git a/src/app/actions/actions/actions.component.html b/src/app/actions/actions/actions.component.html new file mode 100644 index 0000000..8d42f57 --- /dev/null +++ b/src/app/actions/actions/actions.component.html @@ -0,0 +1,31 @@ + +

Redeemable Actions

+ +
+
+ + Filter + + + filter_list {{filter.name}} + + @for (item of filters; track $index) { + {{item.name}} + } + + +
+
+ + Search + + search + +
+
+ + \ No newline at end of file diff --git a/src/app/actions/actions/actions.component.scss b/src/app/actions/actions/actions.component.scss new file mode 100644 index 0000000..15bb90f --- /dev/null +++ b/src/app/actions/actions/actions.component.scss @@ -0,0 +1,23 @@ +body, h3 { + background-color: #fafafa; + padding: 0; + margin: 0; +} + +section { + display: flex; + justify-content: space-between; + width: 70%; + margin-left: auto; + margin-right: auto; + + @media (max-width:1250px) { + display: block; + justify-content: center; + } + + article { + display: flex; + justify-content:space-around; + } +} \ No newline at end of file diff --git a/src/app/actions/actions/actions.component.spec.ts b/src/app/actions/actions/actions.component.spec.ts new file mode 100644 index 0000000..b01eb17 --- /dev/null +++ b/src/app/actions/actions/actions.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ActionsComponent } from './actions.component'; + +describe('ActionsComponent', () => { + let component: ActionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ActionsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ActionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/actions/actions/actions.component.ts b/src/app/actions/actions/actions.component.ts new file mode 100644 index 0000000..895ec0b --- /dev/null +++ b/src/app/actions/actions/actions.component.ts @@ -0,0 +1,93 @@ +import { Component, inject, 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'; + +interface IActionFilter { + name: string + filter: (action: any) => boolean +} + +@Component({ + selector: 'actions', + standalone: true, + imports: [ + ActionListComponent, + ReactiveFormsModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSelectModule + ], + templateUrl: './actions.component.html', + styleUrl: './actions.component.scss' +}) +export class ActionsComponent implements OnInit { + 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') }, + ]; + + client = inject(HermesClientService); + filter = this.filters[0]; + searchControl = new FormControl(''); + search = ''; + items: RedeemableAction[] = []; + + ngOnInit(): void { + this.client.subscribeToRequests('get_redeemable_actions', d => { + this.items = d.data; + }); + this.client.subscribeToRequests('create_redeemable_action', d => { + if (d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id)) { + return; + } + + this.actions.push(d.data); + }); + this.client.subscribeToRequests('update_redeemable_action', d => { + if (d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id)) { + return; + } + + const action = this.actions.find(a => a.name == d.data.name); + if (action) { + action.type = d.data.type; + action.data = d.data.data; + } + }); + this.client.subscribeToRequests('delete_redeemable_action', d => { + // if (d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id)) { + // return; + // } + + this.items = this.actions.filter(a => a.name != d.request.data.name); + }); + + this.client.fetchRedeemableActions(); + } + + get actions(): RedeemableAction[] { + const searchLower = this.search.toLowerCase(); + return this.items.filter(this.filter.filter) + .filter((action) => action.name.toLowerCase().includes(searchLower)); + } + + set actions(value) { + this.items = value; + } + + onFilterChange(event: any): void { + this.filter = this.filters[event]; + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 02f66d3..f1116d2 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -13,7 +13,6 @@ export const appConfig: ApplicationConfig = { provideRouter(routes), provideHttpClient( withInterceptors([(req: HttpRequest, next: HttpHandlerFn) => { - console.log(req.url); return next(req); }]) ), diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 500d6d9..6172085 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,8 +6,8 @@ import { TtsLoginComponent } from './auth/tts-login/tts-login.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 { ActionComponent } from './actions/action/action.component'; import { AuthVisitorGuard } from './shared/auth/auth.visitor.guard'; +import { ActionsComponent } from './actions/actions/actions.component'; export const routes: Routes = [ { @@ -22,7 +22,7 @@ export const routes: Routes = [ }, { path: 'actions', - component: ActionComponent, + component: ActionsComponent, canActivate: [AuthAdminGuard], }, { diff --git a/src/app/auth/auth.module.ts b/src/app/auth/auth.module.ts index ca1988e..86da670 100644 --- a/src/app/auth/auth.module.ts +++ b/src/app/auth/auth.module.ts @@ -2,15 +2,15 @@ import { NgModule } from '@angular/core'; import { LoginComponent } from './login/login.component'; import { TtsLoginComponent } from './tts-login/tts-login.component'; import { ImpersonationComponent } from './impersonation/impersonation.component'; - - +import { UserCardComponent } from './user-card/user-card.component'; @NgModule({ declarations: [], imports: [ LoginComponent, TtsLoginComponent, - ImpersonationComponent + ImpersonationComponent, + UserCardComponent, ] }) export class AuthModule { } diff --git a/src/app/auth/impersonation/impersonation.component.html b/src/app/auth/impersonation/impersonation.component.html index 284cbdd..89c39c1 100644 --- a/src/app/auth/impersonation/impersonation.component.html +++ b/src/app/auth/impersonation/impersonation.component.html @@ -1,19 +1,13 @@ @if (isAdmin()) { - - - Impersonation - Impersonate as another user - - - - User to impersonate - - {{getUsername()}} - @for (user of users; track user.id) { - {{ user.name }} - } - - - - +
+ + User to impersonate + + {{getUsername()}} + @for (user of users; track user.id) { + {{ user.name }} + } + + +
} \ No newline at end of file diff --git a/src/app/auth/impersonation/impersonation.component.scss b/src/app/auth/impersonation/impersonation.component.scss index e69de29..7269665 100644 --- a/src/app/auth/impersonation/impersonation.component.scss +++ b/src/app/auth/impersonation/impersonation.component.scss @@ -0,0 +1,6 @@ +main { + display: flex; + justify-content: center; + align-items: center; + margin-top: 1em; +} \ No newline at end of file diff --git a/src/app/auth/impersonation/impersonation.component.ts b/src/app/auth/impersonation/impersonation.component.ts index a420a41..f6eb2b1 100644 --- a/src/app/auth/impersonation/impersonation.component.ts +++ b/src/app/auth/impersonation/impersonation.component.ts @@ -51,7 +51,6 @@ export class ImpersonationComponent implements OnInit { } public onChange(e: any) { - console.log('impersonate befre', e.value); if (!e.value) { this.http.delete(environment.API_HOST + '/admin/impersonate', { headers: { diff --git a/src/app/auth/tts-login/tts-login.component.html b/src/app/auth/tts-login/tts-login.component.html index 4f3110a..fb8a2c0 100644 --- a/src/app/auth/tts-login/tts-login.component.html +++ b/src/app/auth/tts-login/tts-login.component.html @@ -1,14 +1,23 @@ -

TTS Login

-
- - - API Key - - @for (key of api_keys; track key.id) { - {{key.label}} - } - - - - -
\ No newline at end of file +
+ + + + TTS Login + Web Access to Tom-to-Speech + + + + + API Key + + @for (key of api_keys; track key.id) { + {{key.label}} + } + + + + + + + +
\ No newline at end of file diff --git a/src/app/auth/tts-login/tts-login.component.scss b/src/app/auth/tts-login/tts-login.component.scss index f3681c1..4d22316 100644 --- a/src/app/auth/tts-login/tts-login.component.scss +++ b/src/app/auth/tts-login/tts-login.component.scss @@ -1,14 +1,7 @@ -.main-div { - height: 100vh; - display: flex; - justify-content: center; - align-items: center; -} - -h4 { - text-align: center; -} - -.main-card { - width: 20%; +main { + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; } \ No newline at end of file diff --git a/src/app/auth/tts-login/tts-login.component.ts b/src/app/auth/tts-login/tts-login.component.ts index 250cc60..76297db 100644 --- a/src/app/auth/tts-login/tts-login.component.ts +++ b/src/app/auth/tts-login/tts-login.component.ts @@ -10,61 +10,59 @@ import { Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { environment } from '../../../environments/environment'; import { HermesClientService } from '../../hermes-client.service'; -import { MatCard } from '@angular/material/card'; +import { MatCard, MatCardModule } from '@angular/material/card'; @Component({ - selector: 'tts-login', - standalone: true, - imports: [MatButtonModule, MatCard, MatFormFieldModule, MatSelectModule, MatInputModule, FormsModule], - templateUrl: './tts-login.component.html', - styleUrl: './tts-login.component.scss' + selector: 'tts-login', + standalone: true, + imports: [MatButtonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatInputModule, FormsModule], + templateUrl: './tts-login.component.html', + styleUrl: './tts-login.component.scss' }) export class TtsLoginComponent implements OnInit, OnDestroy { - api_keys: { id: string, label: string }[]; - selected_api_key: string|undefined; + api_keys: { id: string, label: string }[]; + selected_api_key: string | undefined; - private subscription: Subscription|undefined; + private subscription: Subscription | undefined; - constructor(private hermes: HermesClientService, private events: EventService, private http: HttpClient, private router: Router) { - this.api_keys = []; - } + constructor(private hermes: HermesClientService, private events: EventService, private http: HttpClient, private router: Router) { + this.api_keys = []; + } - ngOnInit(): void { - this.http.get(environment.API_HOST + '/keys', { - headers: { - 'Authorization': 'Bearer ' + localStorage.getItem('jwt') - } - }).subscribe((data: any) => this.api_keys = data); + ngOnInit(): void { + this.http.get(environment.API_HOST + '/keys', { + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('jwt') + } + }).subscribe((data: any) => this.api_keys = data); - this.subscription = this.events.listen('tts_login_ack', _ => { - if (document.location.href.includes('/tts-login')) { - this.router.navigate(['/policies']) - } - }); - this.events.listen('tts_logoff', _ => { - this.selected_api_key = undefined; - }); - this.events.listen('impersonation', _ => { - this.selected_api_key = undefined; + this.subscription = this.events.listen('tts_login_ack', _ => { + this.router.navigate(['/policies']) + }); + this.events.listen('tts_logoff', _ => { + this.selected_api_key = undefined; + this.router.navigate(['/tts-login']) + }); + this.events.listen('impersonation', _ => { + this.selected_api_key = undefined; - this.http.get(environment.API_HOST + '/keys', { - headers: { - 'Authorization': 'Bearer ' + localStorage.getItem('jwt') - } - }).subscribe((data: any) => this.api_keys = data); - }); - } + this.http.get(environment.API_HOST + '/keys', { + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('jwt') + } + }).subscribe((data: any) => this.api_keys = data); + }); + } - ngOnDestroy(): void { - if (this.subscription) - this.subscription.unsubscribe(); - } + ngOnDestroy(): void { + if (this.subscription) + this.subscription.unsubscribe(); + } - login() { - console.log('api key for login', this.selected_api_key) - if (!this.selected_api_key) - return; + login() { + if (!this.selected_api_key) + return; - this.hermes.login(this.selected_api_key); - } + this.hermes.login(this.selected_api_key); + } } diff --git a/src/app/auth/user-card/user-card.component.html b/src/app/auth/user-card/user-card.component.html new file mode 100644 index 0000000..02d5fd9 --- /dev/null +++ b/src/app/auth/user-card/user-card.component.html @@ -0,0 +1,20 @@ +@if (auth.isAuthenticated()) { +
+ + + {{username}} + + + + + +
+ @if (isTTSLoggedIn) { + + } + +
+
+
+
+} \ No newline at end of file diff --git a/src/app/auth/user-card/user-card.component.scss b/src/app/auth/user-card/user-card.component.scss new file mode 100644 index 0000000..d0f18a8 --- /dev/null +++ b/src/app/auth/user-card/user-card.component.scss @@ -0,0 +1,23 @@ +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; +} \ No newline at end of file diff --git a/src/app/auth/user-card/user-card.component.spec.ts b/src/app/auth/user-card/user-card.component.spec.ts new file mode 100644 index 0000000..12a9b46 --- /dev/null +++ b/src/app/auth/user-card/user-card.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserCardComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UserCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/auth/user-card/user-card.component.ts b/src/app/auth/user-card/user-card.component.ts new file mode 100644 index 0000000..6748698 --- /dev/null +++ b/src/app/auth/user-card/user-card.component.ts @@ -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(); + } +} diff --git a/src/app/hermes-client.service.ts b/src/app/hermes-client.service.ts index ce5c855..6a330fd 100644 --- a/src/app/hermes-client.service.ts +++ b/src/app/hermes-client.service.ts @@ -15,14 +15,11 @@ export interface Message { }) export class HermesClientService { pipe = new DatePipe('en-US'); - session_id: string|undefined; + session_id: string | undefined; connected: boolean; logged_in: boolean; - private subscriptions: { [key: number]: ((data: any) => void)[] } - constructor(private socket: HermesSocketService, private events: EventService) { - this.subscriptions = {}; this.connected = false; this.logged_in = false; @@ -92,6 +89,18 @@ export class HermesClientService { }); } + public createRedeemableAction(name: string, type: string, d: { [key: string]: any }) { + if (!this.logged_in) + return; + + this.send(3, { + request_id: null, + type: "create_redeemable_action", + data: { name, type, data: d }, + nounce: this.session_id, + }); + } + public createTTSFilter(search: string, replace: string) { if (!this.logged_in) return; @@ -114,6 +123,18 @@ export class HermesClientService { }); } + public deleteRedeemableAction(name: string) { + if (!this.logged_in) + return; + + this.send(3, { + request_id: null, + type: "delete_redeemable_action", + data: { name }, + nounce: this.session_id, + }); + } + public deleteTTSFilter(id: string) { if (!this.logged_in) return; @@ -159,6 +180,17 @@ export class HermesClientService { }); } + public fetchRedeemableActions() { + if (!this.logged_in) + return; + + this.send(3, { + request_id: null, + type: "get_redeemable_actions", + data: null, + }); + } + public heartbeat() { const date = new Date() this.send(0, { @@ -166,11 +198,21 @@ export class HermesClientService { }); } - public subscribe(code: number, action: (data: any) => void) { - if (!(code in this.subscriptions)) { - this.subscriptions[code] = [] - } - this.subscriptions[code].push(action); + public subscribe(code: number, next: (data: any) => void) { + return this.socket.subscribe({ + next: (message: any) => { + if (message.op == code) + next(message.d); + } + }); + } + + public subscribeToRequests(requestType: string, action: (data: any) => void) { + return this.subscribe(4, (data) => { + const type = data.request.type; + if (type == requestType) + action(data); + }); } public updatePolicy(id: string, groupId: string, path: string, usage: number, timespan: number) { @@ -186,6 +228,18 @@ export class HermesClientService { }); } + public updateRedeemableAction(name: string, type: string, d: { [key: string]: any }) { + if (!this.logged_in) + return; + + this.send(3, { + request_id: null, + type: "update_redeemable_action", + data: { name, type, data: d }, + nounce: this.session_id, + }); + } + public updateTTSFilter(id: string, search: string, replace: string) { if (!this.logged_in) return; @@ -207,18 +261,11 @@ export class HermesClientService { console.log("Heartbeat received. Potential connection problem?"); break; case 2: // Login Ack - console.log("Login successful.", message.d.session_id); + console.log("Login successful."); this.logged_in = true; this.session_id = message.d.session_id; this.events.emit('tts_login_ack', null); break; - case 4: // Request Ack - console.log("Request ack received."); - break; - } - if (message.op in this.subscriptions) { - for (let action of this.subscriptions[message.op]) - action(message.d); } }, error: (err: any) => { diff --git a/src/app/hermes-socket.service.ts b/src/app/hermes-socket.service.ts index 5b066a1..9764974 100644 --- a/src/app/hermes-socket.service.ts +++ b/src/app/hermes-socket.service.ts @@ -1,6 +1,6 @@ import { OnInit, Injectable } from '@angular/core'; import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; -import { catchError, filter, first, timeout } from 'rxjs/operators'; +import { catchError, first, timeout } from 'rxjs/operators'; import { environment } from '../environments/environment'; import { Observable, throwError } from 'rxjs'; diff --git a/src/app/navigation/navigation.component.html b/src/app/navigation/navigation.component.html index 9f488db..a3edf46 100644 --- a/src/app/navigation/navigation.component.html +++ b/src/app/navigation/navigation.component.html @@ -1,5 +1,5 @@