Added redemptions page. Fixed some issues. Removed some instances of console.log().
This commit is contained in:
parent
7a7fb832a0
commit
04a50f6db0
2691
package-lock.json
generated
2691
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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>
|
@ -0,0 +1,4 @@
|
|||||||
|
.error {
|
||||||
|
display: block;
|
||||||
|
color: #ba1a1a;
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
90
src/app/actions/action-dropdown/action-dropdown.component.ts
Normal file
90
src/app/actions/action-dropdown/action-dropdown.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -67,10 +67,6 @@ export class ActionsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.client.subscribeToRequests('delete_redeemable_action', d => {
|
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.items = this.actions.filter(a => a.name != d.request.data.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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));
|
this.ngZone.runOutsideAngular(() => setInterval(() => this.client.heartbeat(), 15000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,6 +8,10 @@ import { FiltersComponent } from './tts-filters/filters/filters.component';
|
|||||||
import { AuthAdminGuard } from './shared/auth/auth.admin.guard';
|
import { AuthAdminGuard } from './shared/auth/auth.admin.guard';
|
||||||
import { AuthVisitorGuard } from './shared/auth/auth.visitor.guard';
|
import { AuthVisitorGuard } from './shared/auth/auth.visitor.guard';
|
||||||
import { ActionsComponent } from './actions/actions/actions.component';
|
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 = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@ -24,6 +28,19 @@ export const routes: Routes = [
|
|||||||
path: 'actions',
|
path: 'actions',
|
||||||
component: ActionsComponent,
|
component: ActionsComponent,
|
||||||
canActivate: [AuthAdminGuard],
|
canActivate: [AuthAdminGuard],
|
||||||
|
resolve: {
|
||||||
|
redeemableActions: RedeemableActionResolver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'redemptions',
|
||||||
|
component: RedemptionsComponent,
|
||||||
|
canActivate: [AuthAdminGuard],
|
||||||
|
resolve: {
|
||||||
|
redeemableActions: RedeemableActionResolver,
|
||||||
|
redemptions: RedemptionResolver,
|
||||||
|
twitchRedemptions: TwitchRedemptionResolver,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
|
@ -10,72 +10,72 @@ import { HermesClientService } from '../../hermes-client.service';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'impersonation',
|
selector: 'impersonation',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [MatCardModule, MatSelectModule],
|
imports: [MatCardModule, MatSelectModule],
|
||||||
templateUrl: './impersonation.component.html',
|
templateUrl: './impersonation.component.html',
|
||||||
styleUrl: './impersonation.component.scss'
|
styleUrl: './impersonation.component.scss'
|
||||||
})
|
})
|
||||||
export class ImpersonationComponent implements OnInit {
|
export class ImpersonationComponent implements OnInit {
|
||||||
impersonated: string | undefined;
|
impersonated: string | undefined;
|
||||||
users: { id: string, name: string }[];
|
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) {
|
constructor(private hermes: HermesClientService, private auth: ApiAuthenticationService, private router: Router, private events: EventService, private http: HttpClient, @Inject(PLATFORM_ID) private platformId: Object) {
|
||||||
this.users = []
|
this.users = []
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
this.http.get(environment.API_HOST + '/admin/users', {
|
||||||
if (!isPlatformBrowser(this.platformId)) {
|
headers: {
|
||||||
return;
|
'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.http.get(environment.API_HOST + '/admin/users', {
|
this.hermes.disconnect();
|
||||||
headers: {
|
this.events.emit('impersonation', e.value);
|
||||||
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
|
await this.router.navigate(['tts-login']);
|
||||||
}
|
});
|
||||||
}).subscribe((data: any) => {
|
} else {
|
||||||
this.users = data.filter((d: any) => d.name != this.auth.getUsername());
|
this.http.put(environment.API_HOST + '/admin/impersonate', {
|
||||||
const id = this.auth.getImpersonatedId();
|
impersonation: e.value
|
||||||
if (id && this.users.find(u => u.id == id)) {
|
}, {
|
||||||
this.impersonated = id;
|
headers: {
|
||||||
}
|
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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']);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
|||||||
import { DatePipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
import { HermesSocketService } from './hermes-socket.service';
|
import { HermesSocketService } from './hermes-socket.service';
|
||||||
import EventService from './shared/services/EventService';
|
import EventService from './shared/services/EventService';
|
||||||
import { Observable } from 'rxjs';
|
import { filter, map, Observable } from 'rxjs';
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
d: object,
|
d: object,
|
||||||
@ -48,7 +48,21 @@ export class HermesClientService {
|
|||||||
this.events.emit('tts_logoff', null);
|
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);
|
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) {
|
public createTTSFilter(search: string, replace: string) {
|
||||||
if (!this.logged_in)
|
if (!this.logged_in)
|
||||||
return;
|
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) {
|
public deleteTTSFilter(id: string) {
|
||||||
if (!this.logged_in)
|
if (!this.logged_in)
|
||||||
return;
|
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() {
|
public heartbeat() {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
this.send(0, {
|
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) {
|
public updateTTSFilter(id: string, search: string, replace: string) {
|
||||||
if (!this.logged_in)
|
if (!this.logged_in)
|
||||||
return;
|
return;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { OnInit, Injectable } from '@angular/core';
|
import { OnInit, Injectable } from '@angular/core';
|
||||||
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
|
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 { environment } from '../environments/environment';
|
||||||
import { Observable, throwError } from 'rxjs';
|
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)
|
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));
|
return this.socket.pipe(timeout(3000), catchError((e) => throwError(() => 'No response after 3 seconds.')), first(predicate));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNewWebSocket() {
|
private getNewWebSocket(): WebSocketSubject<any> {
|
||||||
return webSocket({
|
return webSocket({
|
||||||
url: environment.WSS_ENDPOINT
|
url: environment.WSS_ENDPOINT
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendMessage(msg: any) {
|
public sendMessage(msg: any): void {
|
||||||
if (!this.socket || this.socket.closed)
|
if (!this.socket || this.socket.closed)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.socket.next(msg);
|
this.socket.next(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get$(): Observable<any>|undefined {
|
||||||
|
return this.socket?.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
public subscribe(subscriptions: any) {
|
public subscribe(subscriptions: any) {
|
||||||
if (!this.socket || this.socket.closed)
|
if (!this.socket || this.socket.closed)
|
||||||
return;
|
return;
|
||||||
|
@ -26,5 +26,10 @@
|
|||||||
Actions
|
Actions
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a routerLink="/redemptions" routerLinkActive="active" *ngIf="isLoggedIn() && isTTSLoggedIn() && isAdmin()">
|
||||||
|
Redemptions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
@ -28,4 +28,4 @@ export class NavigationComponent {
|
|||||||
isTTSLoggedIn() {
|
isTTSLoggedIn() {
|
||||||
return this.hermes?.logged_in ?? false;
|
return this.hermes?.logged_in ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -44,7 +44,6 @@ export class PolicyTableComponent implements OnInit, OnDestroy {
|
|||||||
for (let policy of response.data) {
|
for (let policy of response.data) {
|
||||||
this.policies.push(new Policy(policy.id, policy.group_id, policy.path, policy.usage, policy.span, "", false, false));
|
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();
|
this.table.renderRows();
|
||||||
} else if (response.request.type == "create_policy") {
|
} 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);
|
const policy = this.policies.find(p => this.groups[response.data.group_id].name == p.temp_group_name && p.path == response.data.path);
|
||||||
|
@ -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>
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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;
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
173
src/app/redemptions/redemption-list/redemption-list.component.ts
Normal file
173
src/app/redemptions/redemption-list/redemption-list.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
21
src/app/redemptions/redemptions.module.ts
Normal file
21
src/app/redemptions/redemptions.module.ts
Normal 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 { }
|
@ -0,0 +1,5 @@
|
|||||||
|
<div class="root">
|
||||||
|
<div class="content">
|
||||||
|
<redemption-list />
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,6 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
26
src/app/redemptions/redemptions/redemptions.component.ts
Normal file
26
src/app/redemptions/redemptions/redemptions.component.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -0,0 +1,4 @@
|
|||||||
|
.error {
|
||||||
|
display: block;
|
||||||
|
color: #ba1a1a;
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
5
src/app/shared/models/group.ts
Normal file
5
src/app/shared/models/group.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
export default interface RedeemableAction {
|
export default interface RedeemableAction {
|
||||||
user_id: string
|
user_id: string;
|
||||||
name: string
|
name: string;
|
||||||
type: string
|
type: string;
|
||||||
data: any
|
data: any;
|
||||||
}
|
}
|
8
src/app/shared/models/redemption.ts
Normal file
8
src/app/shared/models/redemption.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default interface Redemption {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
redemption_id: string;
|
||||||
|
action_name: string
|
||||||
|
order: number;
|
||||||
|
state: boolean;
|
||||||
|
}
|
5
src/app/shared/models/twitch-redemption.ts
Normal file
5
src/app/shared/models/twitch-redemption.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default interface TwitchRedemption {
|
||||||
|
id: string;
|
||||||
|
broadcaster_id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
12
src/app/shared/validators/integer.ts
Normal file
12
src/app/shared/validators/integer.ts
Normal 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 };
|
||||||
|
}
|
14
src/app/shared/validators/of-type.ts
Normal file
14
src/app/shared/validators/of-type.ts
Normal 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.' };
|
||||||
|
}
|
||||||
|
}
|
@ -56,7 +56,6 @@ export class TwitchAuthCallbackComponent implements OnInit {
|
|||||||
localStorage.setItem('jwt', response.token);
|
localStorage.setItem('jwt', response.token);
|
||||||
this.auth.update();
|
this.auth.update();
|
||||||
|
|
||||||
console.log('logged in. so nav to tts login.');
|
|
||||||
await this.router.navigate(['tts-login']);
|
await this.router.navigate(['tts-login']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -2,3 +2,29 @@
|
|||||||
|
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
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);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user