Added redemptions page. Fixed some issues. Removed some instances of console.log().

This commit is contained in:
Tom 2025-01-13 23:37:31 +00:00
parent 7a7fb832a0
commit 04a50f6db0
39 changed files with 2342 additions and 1564 deletions

2691
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
<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>

View File

@ -0,0 +1,4 @@
.error {
display: block;
color: #ba1a1a;
}

View File

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

View File

@ -0,0 +1,90 @@
import { Component, EventEmitter, inject, input, Input, OnInit, Output } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } 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 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>();
private readonly route = inject(ActivatedRoute);
errorMessageKeys: string[] = []
constructor() {
this.route.data.subscribe(data => {
if (!data['redeemableActions'])
return;
this.actions = data['redeemableActions'];
});
}
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;
}
}

View File

@ -67,10 +67,6 @@ export class ActionsComponent implements OnInit {
}
});
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);
});

View File

@ -53,10 +53,6 @@ export class AppComponent implements OnInit, OnDestroy {
}
}));
const connection = this.client.connect();
if (connection) {
this.addSubscription(connection);
}
this.ngZone.runOutsideAngular(() => setInterval(() => this.client.heartbeat(), 15000));
}

View File

@ -8,6 +8,10 @@ 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';
export const routes: Routes = [
{
@ -24,6 +28,19 @@ export const routes: Routes = [
path: 'actions',
component: ActionsComponent,
canActivate: [AuthAdminGuard],
resolve: {
redeemableActions: RedeemableActionResolver,
}
},
{
path: 'redemptions',
component: RedemptionsComponent,
canActivate: [AuthAdminGuard],
resolve: {
redeemableActions: RedeemableActionResolver,
redemptions: RedemptionResolver,
twitchRedemptions: TwitchRedemptionResolver,
}
},
{
path: 'login',

View File

@ -10,72 +10,72 @@ import { HermesClientService } from '../../hermes-client.service';
import { Router } from '@angular/router';
@Component({
selector: 'impersonation',
standalone: true,
imports: [MatCardModule, MatSelectModule],
templateUrl: './impersonation.component.html',
styleUrl: './impersonation.component.scss'
selector: 'impersonation',
standalone: true,
imports: [MatCardModule, MatSelectModule],
templateUrl: './impersonation.component.html',
styleUrl: './impersonation.component.scss'
})
export class ImpersonationComponent implements OnInit {
impersonated: string | undefined;
users: { id: string, name: string }[];
impersonated: string | undefined;
users: { id: string, name: string }[];
constructor(private hermes: HermesClientService, private auth: ApiAuthenticationService, private router: Router, private events: EventService, private http: HttpClient, @Inject(PLATFORM_ID) private platformId: Object) {
this.users = []
constructor(private hermes: HermesClientService, private auth: ApiAuthenticationService, private router: Router, private events: EventService, private http: HttpClient, @Inject(PLATFORM_ID) private platformId: Object) {
this.users = []
}
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
this.http.get(environment.API_HOST + '/admin/users', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe((data: any) => {
this.users = data.filter((d: any) => d.name != this.auth.getUsername());
const id = this.auth.getImpersonatedId();
if (id && this.users.find(u => u.id == id)) {
this.impersonated = id;
}
});
}
public isAdmin() {
return this.auth.isAdmin();
}
public getUsername() {
return this.auth.getUsername();
}
public onChange(e: any) {
if (!e.value) {
this.http.delete(environment.API_HOST + '/admin/impersonate', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
},
body: {
impersonation: e.value
}
this.http.get(environment.API_HOST + '/admin/users', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe((data: any) => {
this.users = data.filter((d: any) => d.name != this.auth.getUsername());
const id = this.auth.getImpersonatedId();
if (id && this.users.find(u => u.id == id)) {
this.impersonated = id;
}
});
}
public isAdmin() {
return this.auth.isAdmin();
}
public getUsername() {
return this.auth.getUsername();
}
public onChange(e: any) {
if (!e.value) {
this.http.delete(environment.API_HOST + '/admin/impersonate', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
},
body: {
impersonation: e.value
}
}).subscribe(async (data: any) => {
this.hermes.disconnect();
this.events.emit('impersonation', e.value);
await this.router.navigate(['tts-login']);
});
} else {
this.http.put(environment.API_HOST + '/admin/impersonate', {
impersonation: e.value
}, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (data: any) => {
this.hermes.disconnect();
this.events.emit('impersonation', e.value);
await this.router.navigate(['tts-login']);
});
}).subscribe(async (data: any) => {
this.hermes.disconnect();
this.events.emit('impersonation', e.value);
await this.router.navigate(['tts-login']);
});
} else {
this.http.put(environment.API_HOST + '/admin/impersonate', {
impersonation: e.value
}, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (data: any) => {
this.hermes.disconnect();
this.events.emit('impersonation', e.value);
await this.router.navigate(['tts-login']);
});
}
}
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HermesSocketService } from './hermes-socket.service';
import EventService from './shared/services/EventService';
import { Observable } from 'rxjs';
import { filter, map, Observable } from 'rxjs';
export interface Message {
d: object,
@ -48,7 +48,21 @@ export class HermesClientService {
this.events.emit('tts_logoff', null);
}
public first(predicate: (data: any) => boolean): Observable<any> | null {
public filter(predicate: (data: any) => boolean): Observable<any>|undefined {
return this.socket.get$()?.pipe(
filter(predicate),
map(d => d.d)
);
}
public filterByRequestType(requestName: string): Observable<any>|undefined {
return this.socket.get$()?.pipe(
filter(d => d.op == 4 && d.d.request.type === requestName),
map(d => d.d)
);
}
public first(predicate: (data: any) => boolean): Observable<any> {
return this.socket.first(predicate);
}
@ -101,6 +115,18 @@ export class HermesClientService {
});
}
public createRedemption(twitchRedemptionId: string, actionName: string, order: number) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "create_redemption",
data: { redemption: twitchRedemptionId, action: actionName, order },
nounce: this.session_id,
});
}
public createTTSFilter(search: string, replace: string) {
if (!this.logged_in)
return;
@ -135,6 +161,18 @@ export class HermesClientService {
});
}
public deleteRedemption(id: string) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "delete_redemption",
data: { id },
nounce: this.session_id,
});
}
public deleteTTSFilter(id: string) {
if (!this.logged_in)
return;
@ -191,6 +229,17 @@ export class HermesClientService {
});
}
public fetchRedemptions() {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "get_redemptions",
data: null,
});
}
public heartbeat() {
const date = new Date()
this.send(0, {
@ -240,6 +289,18 @@ export class HermesClientService {
});
}
public updateRedemption(id: string, twitchRedemptionId: string, actionName: string, order: number) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "update_redemption",
data: { id, redemption: twitchRedemptionId, action: actionName, order, state: true },
nounce: this.session_id,
});
}
public updateTTSFilter(id: string, search: string, replace: string) {
if (!this.logged_in)
return;

View File

@ -1,6 +1,6 @@
import { OnInit, Injectable } from '@angular/core';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { catchError, first, timeout } from 'rxjs/operators';
import { catchError, filter, first, timeout } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { Observable, throwError } from 'rxjs';
@ -22,26 +22,30 @@ export class HermesSocketService implements OnInit {
}
}
public first(predicate: (data: any) => boolean): Observable<any>|null {
public first(predicate: (data: any) => boolean): Observable<any> {
if (!this.socket || this.socket.closed)
return null;
return new Observable().pipe(timeout(3000), catchError((e) => throwError(() => 'No response after 3 seconds.')));
return this.socket.pipe(timeout(3000), catchError((e) => throwError(() => 'No response after 3 seconds.')), first(predicate));
}
private getNewWebSocket() {
private getNewWebSocket(): WebSocketSubject<any> {
return webSocket({
url: environment.WSS_ENDPOINT
});
}
public sendMessage(msg: any) {
public sendMessage(msg: any): void {
if (!this.socket || this.socket.closed)
return;
this.socket.next(msg);
}
public get$(): Observable<any>|undefined {
return this.socket?.asObservable();
}
public subscribe(subscriptions: any) {
if (!this.socket || this.socket.closed)
return;

View File

@ -26,5 +26,10 @@
Actions
</a>
</li>
<li>
<a routerLink="/redemptions" routerLinkActive="active" *ngIf="isLoggedIn() && isTTSLoggedIn() && isAdmin()">
Redemptions
</a>
</li>
</ul>
</nav>

View File

@ -44,7 +44,6 @@ export class PolicyTableComponent implements OnInit, OnDestroy {
for (let policy of response.data) {
this.policies.push(new Policy(policy.id, policy.group_id, policy.path, policy.usage, policy.span, "", false, false));
}
console.log('getting policies', response.data);
this.table.renderRows();
} else if (response.request.type == "create_policy") {
const policy = this.policies.find(p => this.groups[response.data.group_id].name == p.temp_group_name && p.path == response.data.path);

View File

@ -0,0 +1,57 @@
<mat-card>
<mat-card-header>
<mat-card-title>
{{redemption.id ? "Edit" : "Add"}} Redemption
</mat-card-title>
</mat-card-header>
<mat-card-content>
<twitch-redemption-dropdown
ngDefaultControl
[formControl]="redemptionFormControl"
[errorMessages]="redemptionErrorMessages"
[twitchRedemptions]="twitchRedemptions"
[(twitchRedemptionId)]="redemption.redemption_id" />
<action-dropdown
ngDefaultControl
[formControl]="actionFormControl"
[errorMessages]="actionErrorMessages"
[actions]="redeemableActions"
[(action)]="redemption.action_name" />
<mat-form-field>
<mat-label>Order</mat-label>
<input matInput type="number" [formControl]="orderFormControl" [value]="redemption.order" />
@if (orderFormControl.invalid && (orderFormControl.dirty || orderFormControl.touched)) {
@for (error of orderErrorMessageKeys; track $index) {
@if (orderFormControl.hasError(error)) {
<small class="error">{{orderErrorMessages[error]}}</small>
}
}
}
</mat-form-field>
<div class="buttons">
<button
mat-icon-button
class="save"
[disabled]="waitForResponse || formGroups.invalid"
(click)="save()">
<mat-icon>save</mat-icon>
</button>
@if (redemption.id) {
<button
mat-icon-button
class="delete"
[disabled]="waitForResponse"
(click)="delete()">
<mat-icon>delete</mat-icon>
</button>
}
</div>
</mat-card-content>
@if (responseError) {
<mat-card-footer>
<small class="error below">{{responseError}}</small>
</mat-card-footer>
}
</mat-card>

View File

@ -0,0 +1,56 @@
.mat-mdc-card {
margin: 0.5em;
}
.mat-mdc-card-content {
display: flex;
flex-direction: row;
column-gap: 1em;
padding-bottom: 0;
}
.buttons {
margin: -0.75em;
}
button,
.mdc-button {
display: block;
}
.error {
display: block;
color: #ba1a1a;
}
.below {
display: block;
justify-self: center;
}
@media (max-width:1000px) {
.mat-mdc-card-content {
flex-direction: column;
margin-bottom: 0.5em;
display: flex;
justify-content: center;
align-items: center;
margin-top: 1em;
}
.buttons {
margin: 0;
}
button ~ button {
margin-left: 2em;
}
button {
display: inline;
}
.delete {
background-color: rgb(255, 152, 152);
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RedemptionItemEditComponent } from './redemption-item-edit.component';
describe('RedemptionItemEditComponent', () => {
let component: RedemptionItemEditComponent;
let fixture: ComponentFixture<RedemptionItemEditComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RedemptionItemEditComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RedemptionItemEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,145 @@
import { Component, inject, Input, model, OnInit, signal } from '@angular/core';
import Redemption from '../../shared/models/redemption';
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 { MatInputModule } from '@angular/material/input';
import { ActionDropdownComponent } from '../../actions/action-dropdown/action-dropdown.component';
import { TwitchRedemptionDropdownComponent } from '../twitch-redemption-dropdown/twitch-redemption-dropdown.component';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { HermesClientService } from '../../hermes-client.service';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import TwitchRedemption from '../../shared/models/twitch-redemption';
import RedeemableAction from '../../shared/models/redeemable_action';
import { integerValidator } from '../../shared/validators/integer';
import { createTypeValidator } from '../../shared/validators/of-type';
import { RedemptionService } from '../../shared/services/redemption.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'redemption-item-edit',
imports: [
ActionDropdownComponent,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ReactiveFormsModule,
TwitchRedemptionDropdownComponent,
],
templateUrl: './redemption-item-edit.component.html',
styleUrl: './redemption-item-edit.component.scss'
})
export class RedemptionItemEditComponent implements OnInit {
readonly client = inject(HermesClientService);
readonly redemptionService = inject(RedemptionService);
readonly dialogRef = inject(MatDialogRef<RedemptionItemEditComponent>);
readonly data = inject<{ redemption: Redemption, twitchRedemptions: TwitchRedemption[], redeemableActions: RedeemableAction[] }>(MAT_DIALOG_DATA);
redemptionFormControl = new FormControl<TwitchRedemption | string | undefined>(undefined, [Validators.required, createTypeValidator('Object')]);
redemptionErrorMessages: { [errorKey: string]: string } = {
'required': 'This field is required.',
'invalidType': 'Select a value from the dropdown.',
};
actionFormControl = new FormControl<RedeemableAction | string | undefined>(undefined, [Validators.required, createTypeValidator('Object')]);
actionErrorMessages: { [errorKey: string]: string } = {
'required': 'This field is required.',
'invalidType': 'Select a value from the dropdown.',
};
orderFormControl = new FormControl(0, [Validators.required, Validators.min(0), Validators.max(99), integerValidator]);
orderErrorMessages: { [errorKey: string]: string } = {
'required': 'This field is required.',
'min': 'The value must be at least 0.',
'max': 'The value must be at most 99.',
'integer': 'The value must be a whole number.',
};
orderErrorMessageKeys: string[] = [];
formGroups = new FormGroup({
twitchRedemption: this.redemptionFormControl,
redeemableAction: this.actionFormControl,
order: this.orderFormControl,
});
redemption: Redemption = { id: '', user_id: '', redemption_id: '', action_name: '', order: 0, state: true };
twitchRedemptions: TwitchRedemption[] = [];
redeemableActions: RedeemableAction[] = [];
waitForResponse = false;
responseError: string | undefined = undefined;
ngOnInit(): void {
this.redemption = this.data.redemption;
this.orderFormControl.setValue(this.redemption.order);
this.twitchRedemptions = this.data.twitchRedemptions;
this.redeemableActions = this.data.redeemableActions;
this.orderErrorMessageKeys = Object.keys(this.orderErrorMessages);
}
delete() {
const id = this.redemption.id;
this.waitForResponse = true
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'delete_redemption' && d.d.request.data.id == id)
?.subscribe({
next: (d) => {
if (d.d.error) {
this.responseError = d.d.error;
return;
}
this.dialogRef.close(id);
},
error: () => { this.responseError = 'Failed to receive response back from server.'; this.waitForResponse = false; },
complete: () => this.waitForResponse = false,
});
this.client.deleteRedemption(id);
}
save() {
this.responseError = undefined;
const order = this.orderFormControl.value;
if (order == null) {
this.responseError = 'Order must be an integer.';
return;
}
this.waitForResponse = true;
const isNew = !this.redemption.id;
if (isNew) {
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'create_redemption' && d.d.request.data.action == (this.redemption.action_name ?? '') && d.d.request.data.redemption == (this.redemption.redemption_id ?? ''))
?.subscribe({
next: (d) => {
if (d.d.error) {
this.responseError = d.d.error;
return;
}
this.redemption.order = order;
this.dialogRef.close(d.d.data);
},
error: () => { this.responseError = 'Failed to receive response back from server.'; this.waitForResponse = false; },
complete: () => this.waitForResponse = false,
});
this.client.createRedemption(this.redemption.redemption_id, this.redemption.action_name, order);
} else {
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'update_redemption' && d.d.data.id == this.redemption.id)
?.subscribe({
next: (d) => {
if (d.d.error) {
this.responseError = d.d.error;
return;
}
this.redemption.order = order;
this.dialogRef.close(d.d.data);
},
error: () => { this.responseError = 'Failed to receive response back from server.'; this.waitForResponse = false; },
complete: () => this.waitForResponse = false,
});
this.client.updateRedemption(this.redemption.id, this.redemption.redemption_id, this.redemption.action_name, order);
}
}
}

View File

@ -0,0 +1,46 @@
<button mat-button class="add" (click)="add()"><mat-icon>add</mat-icon> Add Redemption</button>
<mat-expansion-panel class="filters-expander" (opened)="panelOpenState.set(true)" (closed)="panelOpenState.set(false)">
<mat-expansion-panel-header>
<mat-panel-title>Filters</mat-panel-title>
<mat-panel-description>
Expand for filtering options
</mat-panel-description>
</mat-expansion-panel-header>
<div class="filters">
<twitch-redemption-dropdown [(twitchRedemptionId)]="filter_redemption" [search]="true" />
<action-dropdown [(action)]="filter_action_name" [search]="true" />
</div>
</mat-expansion-panel>
<div class="table-container">
<table mat-table [dataSource]="redemptions" class="mat-elevation-z8">
<ng-container matColumnDef="twitch-redemption">
<th mat-header-cell *matHeaderCellDef>Twitch Redemption Name</th>
<td mat-cell *matCellDef="let redemption">{{getTwitchRedemptionNameById(redemption.redemption_id) || 'Unknown Twitch Redemption'}}</td>
</ng-container>
<ng-container matColumnDef="action-name">
<th mat-header-cell *matHeaderCellDef>Action Name</th>
<td mat-cell *matCellDef="let redemption">{{redemption.action_name}}</td>
</ng-container>
<ng-container matColumnDef="order">
<th mat-header-cell *matHeaderCellDef>Order</th>
<td mat-cell *matCellDef="let redemption">{{redemption.order}}</td>
</ng-container>
<ng-container matColumnDef="misc">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let redemption">
<button mat-icon-button (click)="openDialog(redemption)">
<mat-icon>edit</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>

View File

@ -0,0 +1,27 @@
.filters-expander {
margin-top: 1em;
margin-bottom: 2em;
}
.filters {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.mat-mdc-table {
overflow: auto;
}
.table-container {
min-width: 555px;
height: 60vh;
overflow: auto;
display: flex;
flex-direction: column;
}
.add {
width: 100%;
margin-top: 3em;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RedemptionListComponent } from './redemption-list.component';
describe('RedemptionListComponent', () => {
let component: RedemptionListComponent;
let fixture: ComponentFixture<RedemptionListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RedemptionListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RedemptionListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,173 @@
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { RedemptionService } from '../../shared/services/redemption.service';
import Redemption from '../../shared/models/redemption';
import { MatCardModule } from '@angular/material/card';
import { TwitchRedemptionDropdownComponent } from "../twitch-redemption-dropdown/twitch-redemption-dropdown.component";
import { MatFormFieldModule } from '@angular/material/form-field';
import { ActivatedRoute } from '@angular/router';
import { ActionDropdownComponent } from '../../actions/action-dropdown/action-dropdown.component';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatTableModule } from '@angular/material/table';
import TwitchRedemption from '../../shared/models/twitch-redemption';
import { RedemptionItemEditComponent } from '../redemption-item-edit/redemption-item-edit.component';
import { MatDialog } from '@angular/material/dialog';
import RedeemableAction from '../../shared/models/redeemable_action';
import { MatExpansionModule } from '@angular/material/expansion';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { Subscription } from 'rxjs';
import { HermesClientService } from '../../hermes-client.service';
@Component({
selector: 'redemption-list',
imports: [
ActionDropdownComponent,
MatButtonModule,
MatCardModule,
MatExpansionModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatTableModule,
ScrollingModule,
TwitchRedemptionDropdownComponent,
],
templateUrl: './redemption-list.component.html',
styleUrl: './redemption-list.component.scss'
})
export class RedemptionListComponent implements OnInit, OnDestroy {
private readonly client = inject(HermesClientService);
private readonly redemptionService = inject(RedemptionService);
private readonly route = inject(ActivatedRoute);
readonly dialog = inject(MatDialog);
private _redemptions: Redemption[] = [];
private _twitchRedemptions: TwitchRedemption[] = [];
private _twitchRedemptionsDict: { [id: string]: string } = {};
private _actions: RedeemableAction[] = [];
displayedColumns: string[] = ['twitch-redemption', 'action-name', 'order', 'misc'];
filter_redemption: string | undefined;
filter_action_name: string | undefined;
readonly panelOpenState = signal(true);
private _subscriptions: Subscription[] = []
constructor() {
this.route.data.subscribe(r => {
this._twitchRedemptions = r['twitchRedemptions'];
this._twitchRedemptionsDict = Object.assign({}, ...r['twitchRedemptions'].map((t: TwitchRedemption) => ({ [t.id]: t.title })));
this._actions = r['redeemableActions'];
let redemptions = r['redemptions'];
redemptions.sort((a: Redemption, b: Redemption) => this.compare(a, b));
this._redemptions = redemptions;
let subscription = this.redemptionService.create$?.subscribe(d => {
if (d.error || !d.data || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id)) {
return;
}
let index = -1;
for (let i = 0; i < this._redemptions.length; i++) {
const comp = this.compare(d.data, this._redemptions[i]);
if (comp < 0) {
index = i;
break;
}
}
this._redemptions.splice(index >= 0 ? index : this._redemptions.length, 0, d.data);
});
if (subscription)
this._subscriptions.push(subscription);
subscription = this.redemptionService.update$?.subscribe(d => {
if (d.error || !d.data || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id))
return;
const redemption = this._redemptions.find(r => r.id = d.data.id);
if (redemption) {
redemption.action_name = d.data.action_name;
redemption.redemption_id = d.data.redemption_id;
redemption.order = d.data.order;
redemption.state = d.data.state;
}
});
if (subscription)
this._subscriptions.push(subscription);
subscription = this.redemptionService.delete$?.subscribe(d => {
if (d.error || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id))
return;
this._redemptions = this._redemptions.filter(r => r.id != d.request.data.id);
});
if (subscription)
this._subscriptions.push(subscription);
});
}
ngOnInit(): void {
}
ngOnDestroy(): void {
this._subscriptions.forEach(s => s.unsubscribe());
}
compare(a: Redemption, b: Redemption) {
return this._twitchRedemptionsDict[a.redemption_id].localeCompare(this._twitchRedemptionsDict[b.redemption_id]) || a.order - b.order;
}
get redemptions() {
const redemptionFilter = this.filter_redemption?.toString().toLowerCase();
const actionFilter = this.filter_action_name?.toString().toLowerCase();
let filtered = this._redemptions.filter(r => !redemptionFilter || this._twitchRedemptionsDict[r.redemption_id].toLowerCase().includes(redemptionFilter));
filtered = filtered.filter(r => !actionFilter || r.action_name.toLowerCase().includes(actionFilter));
return filtered;
}
getTwitchRedemptionNameById(id: string) {
return this._twitchRedemptionsDict[id];
}
add(): void {
this.openDialog({ id: '', user_id: '', redemption_id: '', action_name: '', order: 0, state: true });
}
openDialog(redemption: Redemption): void {
const dialogRef = this.dialog.open(RedemptionItemEditComponent, {
data: { redemption: { ...redemption }, twitchRedemptions: this._twitchRedemptions, redeemableActions: this._actions },
maxWidth: '100vw'
});
dialogRef.afterClosed().subscribe((result: Redemption | string) => {
if (!result)
return;
if (typeof result === 'string') {
// Deleted
this._redemptions = this._redemptions.filter(r => r.id != result);
} else {
const redemption = this._redemptions.find(r => r.id == result.id);
if (redemption) {
// Updated
redemption.action_name = result.action_name;
redemption.redemption_id = result.redemption_id;
redemption.order = result.order;
redemption.state = result.state;
} else {
// Created
let index = -1;
for (let i = 0; i < this._redemptions.length; i++) {
const comp = this.compare(result, this._redemptions[i]);
if (comp < 0) {
index = i;
break;
}
}
this._redemptions.splice(index >= 0 ? index : this._redemptions.length, 0, result);
}
}
});
}
}

View File

@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RedemptionListComponent } from './redemption-list/redemption-list.component';
import { RedemptionsComponent } from './redemptions/redemptions.component';
import { TwitchRedemptionDropdownComponent } from './twitch-redemption-dropdown/twitch-redemption-dropdown.component';
import { RedemptionService } from '../shared/services/redemption.service';
@NgModule({
declarations: [],
imports: [
RedemptionListComponent,
RedemptionsComponent,
TwitchRedemptionDropdownComponent,
],
providers: [
RedemptionService
]
})
export class RedemptionsModule { }

View File

@ -0,0 +1,5 @@
<div class="root">
<div class="content">
<redemption-list />
</div>
</div>

View File

@ -0,0 +1,6 @@
.root {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RedemptionsComponent } from './redemptions.component';
describe('RedemptionsComponent', () => {
let component: RedemptionsComponent;
let fixture: ComponentFixture<RedemptionsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RedemptionsComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RedemptionsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,26 @@
import { Component, inject, OnInit } from '@angular/core';
import { RedemptionListComponent } from "../redemption-list/redemption-list.component";
import { HermesClientService } from '../../hermes-client.service';
import { HttpClient } from '@angular/common/http';
import { RedemptionService } from '../../shared/services/redemption.service';
import { Observable, of } from 'rxjs';
import Redemption from '../../shared/models/redemption';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'redemptions',
imports: [RedemptionListComponent],
templateUrl: './redemptions.component.html',
styleUrl: './redemptions.component.scss'
})
export class RedemptionsComponent implements OnInit {
client = inject(HermesClientService);
http = inject(HttpClient);
route = inject(ActivatedRoute);
redemptionService = inject(RedemptionService);
redemptions: Observable<Redemption[]>|undefined;
ngOnInit(): void {
}
}

View File

@ -0,0 +1,17 @@
<mat-form-field>
<mat-label>Twitch Redemption</mat-label>
<input type="text" matInput placeholder="Pick a Twitch redemption" aria-label="twitch redemption"
[formControl]="formControl" [matAutocomplete]="auto" (blur)="blur()" (input)="input()">
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn" (optionSelected)="select($event.option.value)">
@for (redemption of filteredRedemptions; track redemption.id) {
<mat-option [value]="redemption">{{redemption.title}}</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>

View File

@ -0,0 +1,4 @@
.error {
display: block;
color: #ba1a1a;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TwitchRedemptionDropdownComponent } from './twitch-redemption-dropdown.component';
describe('TwitchRedemptionDropdownComponent', () => {
let component: TwitchRedemptionDropdownComponent;
let fixture: ComponentFixture<TwitchRedemptionDropdownComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TwitchRedemptionDropdownComponent]
})
.compileComponents();
fixture = TestBed.createComponent(TwitchRedemptionDropdownComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,88 @@
import { Component, EventEmitter, inject, input, Input, OnInit, Output } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import TwitchRedemption from '../../shared/models/twitch-redemption';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'twitch-redemption-dropdown',
imports: [MatAutocompleteModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule],
templateUrl: './twitch-redemption-dropdown.component.html',
styleUrl: './twitch-redemption-dropdown.component.scss'
})
export class TwitchRedemptionDropdownComponent implements OnInit {
@Input() formControl = new FormControl<TwitchRedemption | string | undefined>(undefined);
@Input() errorMessages: { [errorKey: string]: string } = {};
@Input() search: boolean = false;
@Input() twitchRedemptions: TwitchRedemption[] = [];
@Input() twitchRedemptionId: string | undefined;
@Output() readonly twitchRedemptionIdChange = new EventEmitter<string>();
private readonly route = inject(ActivatedRoute);
errorMessageKeys: string[] = [];
constructor() {
this.route.data.subscribe(data => {
if (!data['twitchRedemptions'])
return;
this.twitchRedemptions = data['twitchRedemptions'];
});
}
ngOnInit(): void {
this.errorMessageKeys = Object.keys(this.errorMessages);
if (!this.twitchRedemptionId || !this.twitchRedemptions)
return;
const redemption = this.twitchRedemptions.find(r => r.id == this.twitchRedemptionId);
this.formControl.setValue(redemption);
}
get filteredRedemptions() {
const value = this.formControl.value;
if (typeof (value) == 'string') {
return this.twitchRedemptions.filter(r => r.title.toLowerCase().includes(value.toLowerCase()));
}
return this.twitchRedemptions;
}
select(event: TwitchRedemption) {
this.twitchRedemptionIdChange.emit(event.id);
}
input() {
if (this.search && typeof this.formControl.value == 'string') {
this.twitchRedemptionIdChange.emit(this.formControl.value);
}
}
blur() {
if (!this.search && typeof this.formControl.value == 'string') {
const name = this.formControl.value;
const nameLower = name.toLowerCase();
let newValue: TwitchRedemption | undefined = undefined;
const insenstiveActions = this.filteredRedemptions.filter(a => a.title.toLowerCase() == nameLower);
if (insenstiveActions.length > 1) {
const sensitiveAction = insenstiveActions.find(a => a.title == name);
newValue = sensitiveAction ?? undefined;
} else if (insenstiveActions.length == 1) {
newValue = insenstiveActions[0];
}
if (newValue && newValue.id != this.formControl.value) {
this.formControl.setValue(newValue);
this.twitchRedemptionIdChange.emit(newValue.id);
} else if (!newValue)
this.twitchRedemptionIdChange.emit(undefined);
}
}
displayFn(value: any) {
return value?.title;
}
}

View File

@ -0,0 +1,5 @@
export default interface Group {
id: string;
name: string;
priority: number;
}

View File

@ -1,6 +1,6 @@
export default interface RedeemableAction {
user_id: string
name: string
type: string
data: any
user_id: string;
name: string;
type: string;
data: any;
}

View File

@ -0,0 +1,8 @@
export default interface Redemption {
id: string;
user_id: string;
redemption_id: string;
action_name: string
order: number;
state: boolean;
}

View File

@ -0,0 +1,5 @@
export default interface TwitchRedemption {
id: string;
broadcaster_id: string;
title: string;
}

View File

@ -0,0 +1,12 @@
import { AbstractControl, ValidationErrors } from "@angular/forms";
export function integerValidator(control: AbstractControl): ValidationErrors | null {
const value = control.value;
if (!value)
return null;
const matches = (value | 0) == value;
return matches ? null : { integer: true };
}

View File

@ -0,0 +1,14 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
export function createTypeValidator(type: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value)
return null;
const matches = value.constructor.name === type
return matches ? null: { invalidType: 'Invalid choice.' };
}
}

View File

@ -56,7 +56,6 @@ export class TwitchAuthCallbackComponent implements OnInit {
localStorage.setItem('jwt', response.token);
this.auth.update();
console.log('logged in. so nav to tts login.');
await this.router.navigate(['tts-login']);
});
}

View File

@ -2,3 +2,29 @@
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
.mat-mdc-dialog-surface {
background-color: transparent !important;
}
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track {
box-shadow: inset 0 0 5px grey;
border-radius: 10px;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: darkgrey;
border-radius: 10px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: rgb(122, 122, 122);
}