Added pages to see, create, modify & delete redeemable actions. User card top right with disconnect & log out. Code clean up.

This commit is contained in:
Tom
2025-01-08 21:50:18 +00:00
parent 11dfde9a03
commit d595c3500e
41 changed files with 1228 additions and 321 deletions

View File

@ -0,0 +1,94 @@
<body>
<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>
}
</div>
}
</section>
}
</mat-card-content>
<mat-card-actions class="actions" align="end">
@if (!isNew) {
<button mat-raised-button class="delete" (click)="deleteAction(action)">Delete</button>
}
<button mat-raised-button (click)="dialogRef.close()">Cancel</button>
<button mat-raised-button disabled="{{!formsDirty || !formsValidity}}" (click)="save()">Save</button>
</mat-card-actions>
</mat-card>
</body>

View File

@ -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;
}

View File

@ -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();
});
});

View File

@ -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<ActionItemEditComponent>);
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);
}
}

View File

@ -0,0 +1,11 @@
<main>
@for (action of actions; track $index) {
<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>

View File

@ -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;
}
}

View 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();
});
});

View File

@ -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<RedeemableAction>();
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);
});
}
}

View File

@ -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 { }

View File

@ -0,0 +1,31 @@
<body>
<h3>Redeemable Actions</h3>
<section>
<article>
<mat-form-field>
<mat-label>Filter</mat-label>
<mat-select (selectionChange)="onFilterChange($event.value)" value="0">
<mat-select-trigger>
<mat-icon matPrefix>filter_list</mat-icon>&nbsp;{{filter.name}}
</mat-select-trigger>
@for (item of filters; track $index) {
<mat-option value="{{$index}}">{{item.name}}</mat-option>
}
</mat-select>
</mat-form-field>
</article>
<article>
<mat-form-field>
<mat-label>Search</mat-label>
<input matInput
type="text"
placeholder="Name of action"
[formControl]="searchControl"
[(ngModel)]="search">
<mat-icon matPrefix>search</mat-icon>
</mat-form-field>
</article>
</section>
<action-list [actions]="actions" (actionsChange)="items.push($event)" />
</body>

View File

@ -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;
}
}

View 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();
});
});

View File

@ -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];
}
}