Compare commits

..

10 Commits

62 changed files with 559 additions and 615 deletions

View File

@ -1,9 +1,8 @@
import { Component, EventEmitter, inject, input, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute } from '@angular/router';
import RedeemableAction from '../../shared/models/redeemable-action'; import RedeemableAction from '../../shared/models/redeemable-action';
@Component({ @Component({
@ -21,20 +20,9 @@ export class ActionDropdownComponent implements OnInit {
@Input() action: string | undefined; @Input() action: string | undefined;
@Output() readonly actionChange = new EventEmitter<string>(); @Output() readonly actionChange = new EventEmitter<string>();
private readonly route = inject(ActivatedRoute);
errorMessageKeys: string[] = [] errorMessageKeys: string[] = []
constructor() {
this.route.data.subscribe(data => {
if (!data['redeemableActions'])
return;
this.actions = data['redeemableActions'];
});
}
ngOnInit(): void { ngOnInit(): void {
this.errorMessageKeys = Object.keys(this.errorMessages); this.errorMessageKeys = Object.keys(this.errorMessages);

View File

@ -28,10 +28,12 @@ import { MatIconModule } from '@angular/material/icon';
}) })
export class ActionItemEditComponent implements OnInit { export class ActionItemEditComponent implements OnInit {
private readonly client = inject(HermesClientService); private readonly client = inject(HermesClientService);
readonly dialogRef = inject(MatDialogRef<ActionItemEditComponent>);
private readonly data = inject<{ action: RedeemableAction, actions: RedeemableAction[] }>(MAT_DIALOG_DATA); private readonly data = inject<{ action: RedeemableAction, actions: RedeemableAction[] }>(MAT_DIALOG_DATA);
readonly dialogRef = inject(MatDialogRef<ActionItemEditComponent>);
action = this.data.action; action = this.data.action;
actions = this.data.actions; actions = this.data.actions;
readonly actionEntries: ({ [key: string]: any[] }) = { readonly actionEntries: ({ [key: string]: any[] }) = {
'SLEEP': [ 'SLEEP': [
{ {
@ -272,18 +274,18 @@ export class ActionItemEditComponent implements OnInit {
return; return;
} }
this.action.name = this.formGroup.get('name')!.value!;
this.action.type = this.formGroup.get('type')!.value!;
this.action.data = {}
for (const entry of this.actionEntries[this.action.type]) {
this.action.data[entry.key] = entry.control.value!.toString();
}
if (!(this.action.type in this.actionEntries)) { if (!(this.action.type in this.actionEntries)) {
this.waitForResponse = false; this.waitForResponse = false;
return; return;
} }
this.action.name = this.formGroup.get('name')!.value!;
this.action.type = this.formGroup.get('type')!.value!;
this.action.data = {};
for (const entry of this.actionEntries[this.action.type]) {
this.action.data[entry.key] = entry.control.value!.toString();
}
const isNewAction = !this.action.user_id; const isNewAction = !this.action.user_id;
const requestType = isNewAction ? 'create_redeemable_action' : 'update_redeemable_action'; const requestType = isNewAction ? 'create_redeemable_action' : 'update_redeemable_action';
this.client.first((d: any) => d.op == 4 && d.d.request.type == requestType && d.d.data.name == this.action.name) this.client.first((d: any) => d.op == 4 && d.d.request.type == requestType && d.d.data.name == this.action.name)
@ -299,8 +301,8 @@ export class ActionItemEditComponent implements OnInit {
complete: () => this.waitForResponse = false, complete: () => this.waitForResponse = false,
}); });
if (isNewAction) if (isNewAction)
this.client.createRedeemableAction(this.action.name, this.action.type, this.action.data); this.client.createRedeemableAction(this.action.name, this.action.type, false, this.action.data);
else else
this.client.updateRedeemableAction(this.action.name, this.action.type, this.action.data); this.client.updateRedeemableAction(this.action.name, this.action.type, false, this.action.data);
} }
} }

View File

@ -1,5 +1,5 @@
<main> <main>
@for (action of actions; track action.name) { @for (action of actions(); track action.name) {
<button type="button" <button type="button"
class="container" class="container"
(click)="modify(action)"> (click)="modify(action)">

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; import { Component, inject, input } from '@angular/core';
import { MatListModule } from '@angular/material/list'; import { MatListModule } from '@angular/material/list';
import RedeemableAction from '../../shared/models/redeemable-action'; import RedeemableAction from '../../shared/models/redeemable-action';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -23,16 +23,14 @@ import { MatSelectModule } from '@angular/material/select';
styleUrl: './action-list.component.scss' styleUrl: './action-list.component.scss'
}) })
export class ActionListComponent { export class ActionListComponent {
@Input() actions: RedeemableAction[] = [] actions = input.required<RedeemableAction[]>({ alias: 'actions' });
@Output() actionsChange = new EventEmitter<RedeemableAction>();
readonly dialog = inject(MatDialog); readonly dialog = inject(MatDialog);
readonly client = inject(HermesClientService); readonly client = inject(HermesClientService);
opened = false;
create(): void { create(): void {
this.openDialog({ user_id: '', name: '', type: '', data: {} }); this.openDialog({ user_id: '', name: '', type: '', has_message: false, data: {} });
} }
modify(action: RedeemableAction): void { modify(action: RedeemableAction): void {
@ -40,27 +38,8 @@ export class ActionListComponent {
} }
private openDialog(action: RedeemableAction): void { private openDialog(action: RedeemableAction): void {
if (this.opened) this.dialog.open(ActionItemEditComponent, {
return; data: { action: { user_id: action.user_id, name: action.name, type: action.type, data: action.data }, actions: this.actions() },
this.opened = true;
const dialogRef = this.dialog.open(ActionItemEditComponent, {
data: { action: { user_id: action.user_id, name: action.name, type: action.type, data: action.data }, actions: this.actions },
});
const isNewAction = action.name.length <= 0;
dialogRef.afterClosed().subscribe((result: RedeemableAction | undefined) => {
this.opened = false;
if (!result)
return;
if (isNewAction) {
this.actionsChange.emit(result);
} else {
action.type = result.type;
action.data = result.data;
}
}); });
} }
} }

View File

@ -6,7 +6,7 @@
<mat-form-field subscriptSizing="dynamic"> <mat-form-field subscriptSizing="dynamic">
<mat-label>Filter by type</mat-label> <mat-label>Filter by type</mat-label>
<mat-select value="0" <mat-select value="0"
(selectionChange)="onFilterChange($event.value)"> (selectionChange)="filter = filters[$event.value]">
<mat-select-trigger> <mat-select-trigger>
<mat-icon matPrefix>filter_list</mat-icon>&nbsp;{{filter.name}} <mat-icon matPrefix>filter_list</mat-icon>&nbsp;{{filter.name}}
</mat-select-trigger> </mat-select-trigger>
@ -22,13 +22,11 @@
<input matInput <input matInput
type="text" type="text"
placeholder="Name of action" placeholder="Name of action"
[formControl]="searchControl" [formControl]="searchControl" />
[(ngModel)]="search">
<mat-icon matPrefix>search</mat-icon> <mat-icon matPrefix>search</mat-icon>
</mat-form-field> </mat-form-field>
</article> </article>
</section> </section>
<action-list class="list center" <action-list class="list center"
[actions]="actions" [actions]="actions" />
(actionsChange)="items.push($event)" />
</content> </content>

View File

@ -1,4 +1,4 @@
import { Component, Inject, inject, OnInit } from '@angular/core'; import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { ActionListComponent } from "../action-list/action-list.component"; import { ActionListComponent } from "../action-list/action-list.component";
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -10,7 +10,8 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import RedeemableActionService from '../../shared/services/redeemable-action.service'; import RedeemableActionService from '../../shared/services/redeemable-action.service';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { DOCUMENT } from '@angular/common'; import { containsLettersInOrder } from '../../shared/utils/string-compare';
import { Subscription } from 'rxjs';
interface IActionFilter { interface IActionFilter {
name: string name: string
@ -32,7 +33,7 @@ interface IActionFilter {
templateUrl: './actions.component.html', templateUrl: './actions.component.html',
styleUrl: './actions.component.scss' styleUrl: './actions.component.scss'
}) })
export class ActionsComponent implements OnInit { export class ActionsComponent implements OnInit, OnDestroy {
filters: IActionFilter[] = [ filters: IActionFilter[] = [
{ name: 'All', filter: _ => true }, { name: 'All', filter: _ => true },
{ name: 'Local File', filter: data => data.type.includes('_FILE') }, { name: 'Local File', filter: data => data.type.includes('_FILE') },
@ -47,58 +48,28 @@ export class ActionsComponent implements OnInit {
private readonly redeemableActionService = inject(RedeemableActionService); private readonly redeemableActionService = inject(RedeemableActionService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
filter = this.filters[0]; private readonly subscriptions: (Subscription | undefined)[] = [];
searchControl = new FormControl('');
search = '';
items: RedeemableAction[] = [];
constructor(@Inject(DOCUMENT) private document: Document) { } filter = this.filters[0];
searchControl = new FormControl<string>('');
_actions: RedeemableAction[] = [];
ngOnInit(): void { ngOnInit(): void {
this.route.data.subscribe(data => { this.route.data.subscribe(data => this._actions = data['redeemableActions'] || []);
if (!data['redeemableActions'])
return;
this.actions = [...data['redeemableActions']]; this.subscriptions.push(this.redeemableActionService.changes$?.subscribe(a => this._actions = a));
});
this.redeemableActionService.create$?.subscribe(d => {
if (d.error || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id))
return;
this.actions.push(d.data);
});
this.redeemableActionService.update$?.subscribe(d => {
if (d.error || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id))
return;
const action = this.actions.find(a => a.name == d.data.name);
if (action) {
action.type = d.data.type;
action.data = d.data.data;
}
});
this.redeemableActionService.delete$?.subscribe(d => {
if (d.error)
return;
this.items = this.actions.filter(a => a.name != d.request.data.name);
});
this.client.fetchRedeemableActions(); this.client.fetchRedeemableActions();
} }
ngOnDestroy(): void {
this.subscriptions.filter(s => s).forEach(s => s?.unsubscribe());
}
get actions(): RedeemableAction[] { get actions(): RedeemableAction[] {
const searchLower = this.search.toLowerCase(); const searchLower = this.searchControl.value!.toLowerCase();
return this.items.filter(this.filter.filter) return this._actions.filter(this.filter.filter)
.filter((action) => action.name.toLowerCase().includes(searchLower)); .filter((action) => containsLettersInOrder(action.name.toLowerCase(), searchLower));
}
set actions(value) {
this.items = value;
}
onFilterChange(event: any): void {
this.filter = this.filters[event];
} }
} }

View File

@ -1,6 +1,7 @@
<main> <main>
<topbar class="top" /> <topbar class="top" />
<div [class.grid]="isSidebarOpen" <div class="below-topbar"
[class.grid]="isSidebarOpen"
[class.full]="!isSidebarOpen"> [class.full]="!isSidebarOpen">
@if (isSidebarOpen) { @if (isSidebarOpen) {
<sidebar class="navigation" /> <sidebar class="navigation" />

View File

@ -87,7 +87,7 @@ export class AppComponent implements OnInit, OnDestroy {
if (!isPlatformBrowser(this.platformId)) if (!isPlatformBrowser(this.platformId))
return; return;
this.auth.update(); this.auth.update(localStorage.getItem('jwt'));
this.subscriptions.push(this.events.listen('login', async () => await this.router.navigate(['tts-login']))); this.subscriptions.push(this.events.listen('login', async () => await this.router.navigate(['tts-login'])));

View File

@ -1,14 +1,12 @@
@if (isAdmin()) { @if (isAdmin()) {
<main> <mat-form-field class="mat-small"
<mat-form-field class="mat-small"
subscriptSizing="dynamic"> subscriptSizing="dynamic">
<mat-label>User to impersonate</mat-label> <mat-label>User to impersonate</mat-label>
<mat-select [formControl]="impersonationControl"> <mat-select [formControl]="impersonationControl">
<mat-option>{{getUsername()}}</mat-option> <mat-option [value]="auth.getUserId()">{{getUsername()}}</mat-option>
@for (user of users; track user.id) { @for (user of (users$ | async | excludeById : auth.getUserId()); track user.id) {
<mat-option [value]="user.id">{{ user.name }}</mat-option> <mat-option [value]="user.id">{{ user.name }}</mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</main>
} }

View File

@ -1,21 +1,22 @@
import { Component, inject, Inject, OnInit, PLATFORM_ID } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service'; import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { isPlatformBrowser } from '@angular/common';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import EventService from '../../shared/services/EventService'; import EventService from '../../shared/services/EventService';
import { HermesClientService } from '../../hermes-client.service'; import { HermesClientService } from '../../hermes-client.service';
import { Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { User } from '../../shared/models/user';
import { UserService } from '../../shared/services/user.service'; import { UserService } from '../../shared/services/user.service';
import { AsyncPipe } from '@angular/common';
import { ExcludeByIdPipe } from '../../shared/pipes/exclude-by-id.pipe';
@Component({ @Component({
selector: 'impersonation', selector: 'impersonation',
standalone: true, standalone: true,
imports: [ imports: [
AsyncPipe,
ExcludeByIdPipe,
MatCardModule, MatCardModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule, ReactiveFormsModule,
@ -24,34 +25,33 @@ import { UserService } from '../../shared/services/user.service';
styleUrl: './impersonation.component.scss' styleUrl: './impersonation.component.scss'
}) })
export class ImpersonationComponent implements OnInit { export class ImpersonationComponent implements OnInit {
private readonly events = inject(EventService); private readonly client = inject(HermesClientService);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
private readonly events = inject(EventService);
private readonly http = inject(HttpClient);
impersonationControl = new FormControl<string | undefined>(undefined); readonly auth = inject(ApiAuthenticationService);
users: User[];
constructor(private client: HermesClientService, private auth: ApiAuthenticationService, private router: Router, private http: HttpClient, @Inject(PLATFORM_ID) private platformId: Object) { impersonationControl = new FormControl<string>(this.auth.getUserId());
this.users = []; users$ = this.userService.fetch();
}
ngOnInit(): void { ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) { if (!this.auth.isAdmin()) {
return; return;
} }
this.userService.fetch().subscribe(users => { this.users$.subscribe(users => {
this.users = users.filter((d: any) => d.name != this.auth.getUsername());
const id = this.auth.getImpersonatedId(); const id = this.auth.getImpersonatedId();
if (id && this.users.find(u => u.id == id)) { if (id && users.find(u => u.id == id)) {
this.impersonationControl.setValue(id); this.impersonationControl.setValue(id);
} }
}); });
this.impersonationControl.valueChanges.subscribe((impersonationId) => { this.impersonationControl.valueChanges.subscribe((impersonationId) => {
if (!this.auth.isAdmin() || impersonationId == this.auth.getImpersonatedId()) if (impersonationId == this.auth.getImpersonatedId())
return; return;
if (!impersonationId) { if (impersonationId == this.auth.getUserId()) {
this.http.delete(environment.API_HOST + '/admin/impersonate', { this.http.delete(environment.API_HOST + '/admin/impersonate', {
headers: { headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt') 'Authorization': 'Bearer ' + localStorage.getItem('jwt')
@ -73,7 +73,6 @@ export class ImpersonationComponent implements OnInit {
}).subscribe(async (data: any) => { }).subscribe(async (data: any) => {
this.client.disconnect(true); this.client.disconnect(true);
this.events.emit('impersonation', impersonationId); this.events.emit('impersonation', impersonationId);
await this.router.navigate(['tts-login']);
}); });
} }
}); });

View File

@ -0,0 +1,6 @@
<button mat-icon-button
class="neutral"
matTooltip="Navigate to the log in page"
(click)="login()">
<mat-icon>login</mat-icon>
</button>

View File

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

View File

@ -0,0 +1,28 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
@Component({
selector: 'login-button',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatTooltipModule,
ReactiveFormsModule,
],
templateUrl: './login-button.component.html',
styleUrl: './login-button.component.scss'
})
export class LoginButtonComponent {
private readonly router = inject(Router);
login() {
this.router.navigate(['login']);
}
}

View File

@ -0,0 +1,6 @@
<button mat-icon-button
class="danger"
matTooltip="Log off"
(click)="logoff()">
<mat-icon>logout</mat-icon>
</button>

View File

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

View File

@ -0,0 +1,28 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
@Component({
selector: 'logoff-button',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatTooltipModule,
ReactiveFormsModule,
],
templateUrl: './logoff-button.component.html',
styleUrl: './logoff-button.component.scss'
})
export class LogoffButtonComponent {
private readonly auth = inject(ApiAuthenticationService);
logoff() {
this.auth.logout();
}
}

View File

@ -31,10 +31,6 @@ export class ConnectionItemComponent {
constructor(@Inject(DOCUMENT) private document: Document) { } constructor(@Inject(DOCUMENT) private document: Document) { }
ngOnInit() {
console.log('coonnn', this.connection())
}
delete() { delete() {
this.client.deleteConnection(this.connection().name); this.client.deleteConnection(this.connection().name);
} }

View File

@ -1,6 +1,13 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
ul { ul {
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 500px;
overflow: hidden;
@include mat.all-component-densities(-5); @include mat.all-component-densities(-5);
@include mat.form-field-overrides(( @include mat.form-field-overrides((
@ -8,13 +15,6 @@ ul {
outlined-focus-label-text-color: rgb(155, 57, 194), outlined-focus-label-text-color: rgb(155, 57, 194),
outlined-focus-outline-color: rgb(155, 57, 194), outlined-focus-outline-color: rgb(155, 57, 194),
)); ));
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 500px;
overflow: hidden;
} }
ul li { ul li {

View File

@ -37,7 +37,8 @@
[groups]="groups" [groups]="groups"
[policies]="policies" [policies]="policies"
[group]="group?.id" /> [group]="group?.id" />
<policy-table [policies]="policies" /> <policy-table [policies]="policies"
[groups]="groups"/>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel> <mat-expansion-panel>

View File

@ -156,14 +156,14 @@ export class HermesClientService {
}); });
} }
public createRedeemableAction(name: string, type: string, d: { [key: string]: any }) { public createRedeemableAction(name: string, type: string, has_message: boolean, d: { [key: string]: any }) {
if (!this.logged_in) if (!this.logged_in)
return; return;
this.send(3, { this.send(3, {
request_id: null, request_id: null,
type: "create_redeemable_action", type: "create_redeemable_action",
data: { name, type, data: d }, data: { name, type, has_message, data: d },
nounce: this.session_id, nounce: this.session_id,
}); });
} }
@ -439,14 +439,14 @@ export class HermesClientService {
}); });
} }
public updateRedeemableAction(name: string, type: string, d: { [key: string]: any }) { public updateRedeemableAction(name: string, type: string, has_message: boolean, d: { [key: string]: any }) {
if (!this.logged_in) if (!this.logged_in)
return; return;
this.send(3, { this.send(3, {
request_id: null, request_id: null,
type: "update_redeemable_action", type: "update_redeemable_action",
data: { name, type, data: d }, data: { name, type, has_message, data: d },
nounce: this.session_id, nounce: this.session_id,
}); });
} }

View File

@ -1,6 +1,13 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
ul { ul {
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 600px;
overflow: hidden;
@include mat.all-component-densities(-5); @include mat.all-component-densities(-5);
@include mat.form-field-overrides(( @include mat.form-field-overrides((
@ -8,13 +15,6 @@ ul {
outlined-focus-label-text-color: rgb(155, 57, 194), outlined-focus-label-text-color: rgb(155, 57, 194),
outlined-focus-outline-color: rgb(155, 57, 194), outlined-focus-outline-color: rgb(155, 57, 194),
)); ));
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 600px;
overflow: hidden;
} }
ul li { ul li {

View File

@ -28,4 +28,10 @@
</div> </div>
} }
<theme /> <theme />
@if (isLoggedIn) {
<logoff-button />
} @else {
<login-button />
}
</mat-toolbar> </mat-toolbar>

View File

@ -6,10 +6,11 @@ import { MatButtonModule } from '@angular/material/button';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service'; import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
import { ImpersonationComponent } from '../../auth/impersonation/impersonation.component'; import { ImpersonationComponent } from '../../auth/impersonation/impersonation.component';
import { HermesClientService } from '../../hermes-client.service';
import EventService from '../../shared/services/EventService'; import EventService from '../../shared/services/EventService';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { ThemeComponent } from "../../theme/theme.component"; import { ThemeComponent } from "../../theme/theme.component";
import { LogoffButtonComponent } from "../../auth/logoff-button/logoff-button.component";
import { LoginButtonComponent } from "../../auth/login-button/login-button.component";
@Component({ @Component({
selector: 'topbar', selector: 'topbar',
@ -21,6 +22,8 @@ import { ThemeComponent } from "../../theme/theme.component";
MatIconModule, MatIconModule,
MatToolbarModule, MatToolbarModule,
ThemeComponent, ThemeComponent,
LoginButtonComponent,
LogoffButtonComponent,
], ],
providers: [AuthVisitorGuard], providers: [AuthVisitorGuard],
templateUrl: './topbar.component.html', templateUrl: './topbar.component.html',
@ -28,14 +31,13 @@ import { ThemeComponent } from "../../theme/theme.component";
}) })
export class Topbar implements OnDestroy { export class Topbar implements OnDestroy {
private readonly auth = inject(ApiAuthenticationService); private readonly auth = inject(ApiAuthenticationService);
private readonly client = inject(HermesClientService);
private readonly events = inject(EventService); private readonly events = inject(EventService);
private subscriptions: (Subscription | null)[] = []; private subscriptions: (Subscription | null)[] = [];
private _showImpersonation: boolean = false private _showImpersonation: boolean = false
constructor() { constructor() {
this.subscriptions.push(this.events.listen('impersonation', () => this.showImpersonation = false)); this.subscriptions.push(this.events.listen('impersonation', () => { this.auth.update(localStorage.getItem('jwt')); this.showImpersonation = false; }));
} }
ngOnDestroy(): void { ngOnDestroy(): void {

View File

@ -1,6 +1,13 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
ul { ul {
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 500px;
overflow: hidden;
@include mat.all-component-densities(-5); @include mat.all-component-densities(-5);
@include mat.form-field-overrides(( @include mat.form-field-overrides((
@ -8,13 +15,6 @@ ul {
outlined-focus-label-text-color: rgb(155, 57, 194), outlined-focus-label-text-color: rgb(155, 57, 194),
outlined-focus-outline-color: rgb(155, 57, 194), outlined-focus-outline-color: rgb(155, 57, 194),
)); ));
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 500px;
overflow: hidden;
} }
ul li { ul li {

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; import { Component, inject, input } from '@angular/core';
import { Policy } from '../../shared/models/policy'; import { Policy } from '../../shared/models/policy';
import { PolicyItemEditComponent } from '../policy-item-edit/policy-item-edit.component'; import { PolicyItemEditComponent } from '../policy-item-edit/policy-item-edit.component';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@ -17,28 +17,21 @@ import { Group } from '../../shared/models/group';
}) })
export class PolicyAddButtonComponent { export class PolicyAddButtonComponent {
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
@Input({ required: true }) policies: Policy[] = [];
@Input({ required: true }) groups: Group[] = []; policies = input.required<Policy[]>();
@Input() group: string | undefined = undefined; groups = input.required<Group[]>();
@Output() policy = new EventEmitter<Policy>(); group = input<string | undefined>();
openDialog(): void { openDialog(): void {
const dialogRef = this.dialog.open(PolicyItemEditComponent, { this.dialog.open(PolicyItemEditComponent, {
data: { data: {
policies: this.policies, policies: this.policies(),
groups: this.groups, groups: this.groups(),
group_id: this.group, group_id: this.group(),
groupDisabled: !!this.group, groupDisabled: !!this.group(),
isNew: true, isNew: true,
} }
}); });
dialogRef.afterClosed().subscribe((result: Policy) => {
if (!result)
return;
this.policy.emit(result);
});
} }
} }

View File

@ -7,8 +7,11 @@
[formControl]="policyControl" [formControl]="policyControl"
[matAutocomplete]="auto" /> [matAutocomplete]="auto" />
<mat-autocomplete #auto="matAutocomplete"> <mat-autocomplete #auto="matAutocomplete">
@for (option of filteredPolicies | async; track option) { @for (option of filteredPolicies | async; track option.path) {
<mat-option [value]="option">{{option}}</mat-option> <mat-option [value]="option.path">
<p class="path">{{option.path}}</p>
<p class="description muted">{{option.description}}</p>
</mat-option>
} }
</mat-autocomplete> </mat-autocomplete>
@if (policyControl.invalid && (policyControl.dirty || policyControl.touched)) { @if (policyControl.invalid && (policyControl.dirty || policyControl.touched)) {

View File

@ -0,0 +1,12 @@
p {
margin: 0;
padding: 0;
}
.description {
font-size: smaller;
}
.muted {
color: #999999
}

View File

@ -4,9 +4,14 @@ import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angu
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { map, Observable, startWith } from 'rxjs'; import { EMPTY, map, Observable, startWith } from 'rxjs';
const Policies = [ interface PolicyData {
path: string;
description: string;
}
const Policies: PolicyData[] = [
{ path: "tts", description: "Anything to do with TTS" }, { path: "tts", description: "Anything to do with TTS" },
{ path: "tts.chat", description: "Anything to do with chat" }, { path: "tts.chat", description: "Anything to do with chat" },
{ path: "tts.chat.bits.read", description: "To read chat messages with bits via TTS" }, { path: "tts.chat.bits.read", description: "To read chat messages with bits via TTS" },
@ -43,14 +48,7 @@ const Policies = [
export class PolicyDropdownComponent { export class PolicyDropdownComponent {
@Input() policy: string | null = ''; @Input() policy: string | null = '';
@Input({ alias: 'control' }) policyControl = new FormControl('', [Validators.required]); @Input({ alias: 'control' }) policyControl = new FormControl('', [Validators.required]);
filteredPolicies: Observable<string[]>; filteredPolicies: Observable<PolicyData[]> = EMPTY;
constructor() {
this.filteredPolicies = this.policyControl.valueChanges.pipe(
startWith(''),
map(value => this._filter(value || '')),
);
}
ngOnInit() { ngOnInit() {
this.policyControl.setValue(this.policy); this.policyControl.setValue(this.policy);
@ -60,13 +58,12 @@ export class PolicyDropdownComponent {
); );
} }
private _filter(value: string): string[] { private _filter(value: string): PolicyData[] {
const filterValue = value.toLowerCase(); const filterValue = value.toLowerCase();
const names = Policies.map(p => p.path); if (Policies.map(p => p.path).includes(filterValue)) {
if (names.includes(filterValue)) { return Policies;
return names;
} }
return names.filter(option => option.toLowerCase().includes(filterValue)); return Policies.filter(option => option.path.toLowerCase().includes(filterValue));
} }
} }

View File

@ -1,5 +1,5 @@
<table mat-table <table mat-table
[dataSource]="policies" [dataSource]="policies()"
class="mat-elevation-z8"> class="mat-elevation-z8">
<ng-container matColumnDef="path"> <ng-container matColumnDef="path">
<th mat-header-cell <th mat-header-cell

View File

@ -1,79 +1,48 @@
import { AfterViewInit, Component, inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; import { Component, inject, input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MatTable, MatTableModule } from '@angular/material/table'; import { MatTable, MatTableModule } from '@angular/material/table';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import EventService from '../../shared/services/EventService';
import { Policy } from '../../shared/models/policy'; import { Policy } from '../../shared/models/policy';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HermesClientService } from '../../hermes-client.service'; import { HermesClientService } from '../../hermes-client.service';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute } from '@angular/router';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { PolicyItemEditComponent } from '../policy-item-edit/policy-item-edit.component'; import { PolicyItemEditComponent } from '../policy-item-edit/policy-item-edit.component';
import { Group } from '../../shared/models/group'; import { Group } from '../../shared/models/group';
import PolicyService from '../../shared/services/policy.service';
@Component({ @Component({
selector: 'policy-table', selector: 'policy-table',
imports: [FormsModule, MatButtonModule, MatTableModule, MatIconModule], imports: [
FormsModule,
MatButtonModule,
MatTableModule,
MatIconModule,
],
templateUrl: './policy-table.component.html', templateUrl: './policy-table.component.html',
styleUrl: './policy-table.component.scss' styleUrl: './policy-table.component.scss'
}) })
export class PolicyTableComponent implements OnInit, OnDestroy, AfterViewInit { export class PolicyTableComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly client = inject(HermesClientService);
private readonly hermes = inject(HermesClientService); private readonly policyService = inject(PolicyService);
private readonly events = inject(EventService);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
@Input() policies: Policy[] = []; policies = input.required<Policy[]>();
groups = input.required<Group[]>();
@ViewChild(MatTable) table: MatTable<Policy>; @ViewChild(MatTable) table: MatTable<Policy>;
readonly displayedColumns = ['path', 'group', 'usage', 'span', 'actions']; readonly displayedColumns = ['path', 'group', 'usage', 'span', 'actions'];
private readonly _subscriptions: any[] = []; private readonly _subscriptions: any[] = [];
groups: Group[] = [];
constructor() { constructor() {
this.table = {} as MatTable<Policy>; this.table = {} as MatTable<Policy>;
} }
ngOnInit(): void { ngOnInit(): void {
this.route.data.subscribe(r => { this._subscriptions.push(this.policyService.create$?.subscribe(_ => this.table.renderRows()));
this.groups = [...r['groups']]; this._subscriptions.push(this.policyService.update$?.subscribe(_ => this.table.renderRows()));
}); this._subscriptions.push(this.policyService.delete$?.subscribe(_ => this.table.renderRows()));
this._subscriptions.push(this.events.listen('addPolicy', (payload) => {
if (!payload || this.policies.map(p => p.path).includes(payload))
return;
this.policies.push(payload);
this.table.renderRows();
}));
this._subscriptions.push(this.hermes.subscribeToRequests('create_policy', response => {
const policy = this.policies.find(p => p.path == response.data.path);
if (policy == null) {
this.policies.push(response.data);
}
this.table.renderRows();
}));
this._subscriptions.push(this.hermes.subscribeToRequests('update_policy', response => {
const policy = this.policies.find(p => p.id == response.data.id);
if (policy != null) {
policy.id = response.data.id;
policy.group_id = response.data.group_id;
}
this.table.renderRows();
}));
this._subscriptions.push(this.hermes.subscribeToRequests('delete_policy', response => {
this.policies = this.policies.filter(p => p.id != response.request.data.id);
this.table.renderRows();
}));
}
ngAfterViewInit(): void {
this.table.renderRows();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@ -81,33 +50,23 @@ export class PolicyTableComponent implements OnInit, OnDestroy, AfterViewInit {
} }
delete(policy: Policy) { delete(policy: Policy) {
this.hermes.deletePolicy(policy.id); this.client.deletePolicy(policy.id);
} }
edit(policy: Policy) { edit(policy: Policy) {
const dialogRef = this.dialog.open(PolicyItemEditComponent, { this.dialog.open(PolicyItemEditComponent, {
data: { data: {
policies: this.policies, policies: this.policies(),
groups: this.groups, groups: this.groups(),
policy_id: policy.id, policy_id: policy.id,
group_id: policy.group_id, group_id: policy.group_id,
groupDisabled: true, groupDisabled: true,
isNew: false, isNew: false,
} }
}); });
dialogRef.afterClosed().subscribe((result: Policy) => {
if (!result)
return;
policy.group_id = result.group_id;
policy.path = result.path;
policy.usage = result.usage;
policy.span = result.span;
});
} }
getGroupById(group_id: string) { getGroupById(group_id: string) {
return this.groups.find((g: Group) => g.id == group_id); return this.groups().find((g: Group) => g.id == group_id);
} }
} }

View File

@ -1,10 +1,10 @@
<h4>Policies</h4> <h4>Policies</h4>
<div class="add"> <div class="add">
<policy-add-button [policies]="policies" <policy-add-button [policies]="policies"
[groups]="groups" [groups]="groups" />
(policy)="addPolicy($event)" />
</div> </div>
<div> <div>
<policy-table [policies]="policies" /> <policy-table [policies]="policies"
[groups]="groups" />
</div> </div>

View File

@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, inject, OnDestroy } from '@angular/core';
import { PolicyTableComponent } from "../policy-table/policy-table.component"; import { PolicyTableComponent } from "../policy-table/policy-table.component";
import { Policy } from '../../shared/models/policy'; import { Policy } from '../../shared/models/policy';
import { ActivatedRoute, RouterModule } from '@angular/router'; import { ActivatedRoute, RouterModule } from '@angular/router';
@ -6,43 +6,55 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { PolicyAddButtonComponent } from "../policy-add-button/policy-add-button.component"; import { PolicyAddButtonComponent } from "../policy-add-button/policy-add-button.component";
import { Group } from '../../shared/models/group'; import { Group } from '../../shared/models/group';
import PolicyService from '../../shared/services/policy.service';
import GroupService from '../../shared/services/group.service';
@Component({ @Component({
selector: 'policy', selector: 'policy',
imports: [MatButtonModule, MatIconModule, PolicyTableComponent, RouterModule, PolicyAddButtonComponent], imports: [
MatButtonModule,
MatIconModule,
PolicyTableComponent,
RouterModule,
PolicyAddButtonComponent,
],
templateUrl: './policy.component.html', templateUrl: './policy.component.html',
styleUrl: './policy.component.scss' styleUrl: './policy.component.scss'
}) })
export class PolicyComponent { export class PolicyComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly policyService = inject(PolicyService);
private readonly groupService = inject(GroupService);
private readonly _subscriptions: any[] = [];
private _policies: Policy[] = []; private _policies: Policy[] = [];
groups: Group[] = []; private _groups: Group[] = [];
constructor() { constructor() {
this.route.data.subscribe((data) => { this.route.data.subscribe((data) => {
const policies = [...data['policies']]; this._policies = data['policies'];
policies.sort(this.compare); this._groups = data['groups'];
this._policies = policies;
this.groups = [...data['groups']];
}); });
this._subscriptions.push(this.policyService.delete$?.subscribe(_ =>
this.policyService.fetch().subscribe(p => this._policies = p)));
this._subscriptions.push(this.groupService.deleteGroup$?.subscribe(_ =>
this.groupService.fetch().subscribe(g => this._groups = g.groups)));
}
ngOnDestroy(): void {
this._subscriptions.filter(s => !!s).forEach(s => s.unsubscribe());
} }
get policies() { get policies() {
return this._policies; return this._policies;
} }
addPolicy(policy: Policy) { get groups() {
let index = -1; return this._groups;
for (let i = 0; i < this._policies.length; i++) {
const comp = this.compare(policy, this._policies[i]);
if (comp < 0) {
index = i;
break;
}
}
this._policies.splice(index >= 0 ? index : this._policies.length, 0, policy);
} }
compare(a: Policy, b: Policy) { compare(a: Policy, b: Policy) {

View File

@ -1,4 +1,4 @@
import { Component, inject, Input, model, OnInit, signal } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import Redemption from '../../shared/models/redemption'; import Redemption from '../../shared/models/redemption';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
@ -14,8 +14,6 @@ import TwitchRedemption from '../../shared/models/twitch-redemption';
import RedeemableAction from '../../shared/models/redeemable-action'; import RedeemableAction from '../../shared/models/redeemable-action';
import { integerValidator } from '../../shared/validators/integer'; import { integerValidator } from '../../shared/validators/integer';
import { createTypeValidator } from '../../shared/validators/of-type'; import { createTypeValidator } from '../../shared/validators/of-type';
import RedemptionService from '../../shared/services/redemption.service';
import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'redemption-item-edit', selector: 'redemption-item-edit',
@ -33,10 +31,9 @@ import { Subscription } from 'rxjs';
styleUrl: './redemption-item-edit.component.scss' styleUrl: './redemption-item-edit.component.scss'
}) })
export class RedemptionItemEditComponent implements OnInit { export class RedemptionItemEditComponent implements OnInit {
readonly client = inject(HermesClientService); private readonly client = inject(HermesClientService);
readonly redemptionService = inject(RedemptionService); private readonly dialogRef = inject(MatDialogRef<RedemptionItemEditComponent>);
readonly dialogRef = inject(MatDialogRef<RedemptionItemEditComponent>); private readonly data = inject<{ redemption: Redemption, twitchRedemptions: { [id: string]: TwitchRedemption }, redeemableActions: RedeemableAction[] }>(MAT_DIALOG_DATA);
readonly data = inject<{ redemption: Redemption, twitchRedemptions: TwitchRedemption[], redeemableActions: RedeemableAction[] }>(MAT_DIALOG_DATA);
redemptionFormControl = new FormControl<TwitchRedemption | string | undefined>(undefined, [Validators.required, createTypeValidator('Object')]); redemptionFormControl = new FormControl<TwitchRedemption | string | undefined>(undefined, [Validators.required, createTypeValidator('Object')]);
redemptionErrorMessages: { [errorKey: string]: string } = { redemptionErrorMessages: { [errorKey: string]: string } = {
@ -66,16 +63,17 @@ export class RedemptionItemEditComponent implements OnInit {
}); });
redemption: Redemption = { id: '', user_id: '', redemption_id: '', action_name: '', order: 0, state: true }; redemption: Redemption = { id: '', user_id: '', redemption_id: '', action_name: '', order: 0, state: true };
twitchRedemptions: TwitchRedemption[] = []; twitchRedemptions: { [id: string]: TwitchRedemption } = {};
redeemableActions: RedeemableAction[] = []; redeemableActions: RedeemableAction[] = [];
waitForResponse = false; waitForResponse = false;
responseError: string | undefined = undefined; responseError: string | undefined = undefined;
ngOnInit(): void { ngOnInit(): void {
this.redemption = this.data.redemption; this.redemption = this.data.redemption;
this.orderFormControl.setValue(this.redemption.order);
this.twitchRedemptions = this.data.twitchRedemptions; this.twitchRedemptions = this.data.twitchRedemptions;
this.redeemableActions = this.data.redeemableActions; this.redeemableActions = this.data.redeemableActions;
this.orderFormControl.setValue(this.redemption.order);
this.orderErrorMessageKeys = Object.keys(this.orderErrorMessages); this.orderErrorMessageKeys = Object.keys(this.orderErrorMessages);
} }
@ -84,7 +82,9 @@ export class RedemptionItemEditComponent implements OnInit {
if (this.waitForResponse) if (this.waitForResponse)
return; return;
this.waitForResponse = true this.waitForResponse = true;
this.responseError = undefined;
const id = this.redemption.id; const id = this.redemption.id;
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'delete_redemption' && d.d.request.data.id == id) this.client.first((d: any) => d.op == 4 && d.d.request.type == 'delete_redemption' && d.d.request.data.id == id)
?.subscribe({ ?.subscribe({
@ -107,6 +107,7 @@ export class RedemptionItemEditComponent implements OnInit {
this.waitForResponse = true; this.waitForResponse = true;
this.responseError = undefined; this.responseError = undefined;
const order = this.orderFormControl.value; const order = this.orderFormControl.value;
if (order == null) { if (order == null) {
this.responseError = 'Order must be an integer.'; this.responseError = 'Order must be an integer.';
@ -114,16 +115,14 @@ export class RedemptionItemEditComponent implements OnInit {
return; return;
} }
this.waitForResponse = true;
const isNew = !this.redemption.id; const isNew = !this.redemption.id;
if (isNew) { 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 ?? '')) 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({ ?.subscribe({
next: (d) => { next: (d) => {
if (d.d.error) { if (d.d.error) {
this.responseError = d.d.error; this.responseError = d.d.error;
} else { } else {
this.redemption.order = order;
this.dialogRef.close(d.d.data); this.dialogRef.close(d.d.data);
} }
}, },
@ -132,13 +131,13 @@ export class RedemptionItemEditComponent implements OnInit {
}); });
this.client.createRedemption(this.redemption.redemption_id, this.redemption.action_name, order); this.client.createRedemption(this.redemption.redemption_id, this.redemption.action_name, order);
} else { } else {
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'update_redemption' && d.d.data.id == this.redemption.id) this.client.first((d: any) => d.op == 4 && d.d.request.type == 'update_redemption' && d.d.data.id == this.redemption.id)
?.subscribe({ ?.subscribe({
next: (d) => { next: (d) => {
if (d.d.error) { if (d.d.error) {
this.responseError = d.d.error; this.responseError = d.d.error;
} else { } else {
this.redemption.order = order;
this.dialogRef.close(d.d.data); this.dialogRef.close(d.d.data);
} }
}, },

View File

@ -14,10 +14,12 @@
</mat-expansion-panel-header> </mat-expansion-panel-header>
<div class="filters"> <div class="filters">
<twitch-redemption-dropdown [(twitchRedemptionId)]="filter_redemption" <twitch-redemption-dropdown [twitchRedemptions]="twitchRedemptions()"
[search]="true" /> [(twitchRedemptionId)]="filter_redemption"
<action-dropdown [(action)]="filter_action_name"
[search]="true" /> [search]="true" />
<action-dropdown [search]="true"
[actions]="actions()"
[(action)]="filter_action_name" />
</div> </div>
</mat-expansion-panel> </mat-expansion-panel>

View File

@ -1,23 +1,19 @@
import { Component, inject, OnDestroy, signal } from '@angular/core'; import { Component, inject, input, signal } from '@angular/core';
import RedemptionService from '../../shared/services/redemption.service';
import Redemption from '../../shared/models/redemption'; import Redemption from '../../shared/models/redemption';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { TwitchRedemptionDropdownComponent } from "../twitch-redemption-dropdown/twitch-redemption-dropdown.component"; import { TwitchRedemptionDropdownComponent } from "../twitch-redemption-dropdown/twitch-redemption-dropdown.component";
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { ActivatedRoute } from '@angular/router';
import { ActionDropdownComponent } from '../../actions/action-dropdown/action-dropdown.component'; import { ActionDropdownComponent } from '../../actions/action-dropdown/action-dropdown.component';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import TwitchRedemption from '../../shared/models/twitch-redemption';
import { RedemptionItemEditComponent } from '../redemption-item-edit/redemption-item-edit.component'; import { RedemptionItemEditComponent } from '../redemption-item-edit/redemption-item-edit.component';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import RedeemableAction from '../../shared/models/redeemable-action'; import RedeemableAction from '../../shared/models/redeemable-action';
import { MatExpansionModule } from '@angular/material/expansion'; import { MatExpansionModule } from '@angular/material/expansion';
import { ScrollingModule } from '@angular/cdk/scrolling'; import { ScrollingModule } from '@angular/cdk/scrolling';
import { Subscription } from 'rxjs'; import { toTwitchRedemptionDict } from '../../shared/transformers/twitch-redemption.transformer';
import { HermesClientService } from '../../hermes-client.service';
@Component({ @Component({
selector: 'redemption-list', selector: 'redemption-list',
@ -36,97 +32,44 @@ import { HermesClientService } from '../../hermes-client.service';
templateUrl: './redemption-list.component.html', templateUrl: './redemption-list.component.html',
styleUrl: './redemption-list.component.scss' styleUrl: './redemption-list.component.scss'
}) })
export class RedemptionListComponent implements OnDestroy { export class RedemptionListComponent {
private readonly client = inject(HermesClientService);
private readonly redemptionService = inject(RedemptionService);
private readonly route = inject(ActivatedRoute);
readonly dialog = inject(MatDialog); 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']; displayedColumns: string[] = ['twitch-redemption', 'action-name', 'order', 'misc'];
filter_redemption: string | undefined; filter_redemption: string | undefined;
filter_action_name: string | undefined; filter_action_name: string | undefined;
readonly panelOpenState = signal(false); readonly panelOpenState = signal(false);
private _subscriptions: Subscription[] = []
_redemptions = input.required<Redemption[]>({ alias: 'redemptions' });
constructor() { twitchRedemptions = input.required({
this.route.data.subscribe(r => { alias: 'twitchRedemptions',
this._twitchRedemptions = r['twitchRedemptions']; transform: toTwitchRedemptionDict,
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;
}); });
actions = input.required<RedeemableAction[]>();
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() {
this.panelOpenState.set(false);
}
ngOnDestroy(): void {
this._subscriptions.forEach(s => s.unsubscribe());
}
compare(a: Redemption, b: Redemption) { compare(a: Redemption, b: Redemption) {
return this._twitchRedemptionsDict[a.redemption_id].localeCompare(this._twitchRedemptionsDict[b.redemption_id]) || a.order - b.order; const dict = this.twitchRedemptions();
if (!dict) {
return 0;
}
return dict[a.redemption_id]?.title.localeCompare(dict[b.redemption_id]?.title) || a.order - b.order;
} }
get redemptions() { get redemptions() {
const redemptionFilter = this.filter_redemption?.toString().toLowerCase();
const actionFilter = this.filter_action_name?.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)); const redemptionFilter = this.filter_redemption?.toString().toLowerCase();
let filtered = this._redemptions();
if (redemptionFilter) {
filtered = filtered.filter(r => this.twitchRedemptions()![r.redemption_id]?.title.toLowerCase().includes(redemptionFilter) || r.redemption_id == redemptionFilter);
}
if (actionFilter) {
filtered = filtered.filter(r => !actionFilter || r.action_name.toLowerCase().includes(actionFilter)); filtered = filtered.filter(r => !actionFilter || r.action_name.toLowerCase().includes(actionFilter));
}
return filtered; return filtered;
} }
getTwitchRedemptionNameById(id: string) { getTwitchRedemptionNameById(id: string) {
return this._twitchRedemptionsDict[id]; return this.twitchRedemptions()![id]?.title;
} }
add(): void { add(): void {
@ -134,39 +77,9 @@ export class RedemptionListComponent implements OnDestroy {
} }
openDialog(redemption: Redemption): void { openDialog(redemption: Redemption): void {
const dialogRef = this.dialog.open(RedemptionItemEditComponent, { this.dialog.open(RedemptionItemEditComponent, {
data: { redemption: { ...redemption }, twitchRedemptions: this._twitchRedemptions, redeemableActions: this._actions }, data: { redemption: { ...redemption }, twitchRedemptions: this.twitchRedemptions(), redeemableActions: this.actions() },
maxWidth: '100vw' 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

@ -1,5 +1,7 @@
<div class="root"> <div class="root">
<div class="content"> <div class="content">
<redemption-list /> <redemption-list [redemptions]="redemptions$ | async"
[twitchRedemptions]="twitchRedemptions$ | async"
[actions]="actions$ | async" />
</div> </div>
</div> </div>

View File

@ -1,26 +1,25 @@
import { Component, inject, OnInit } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RedemptionListComponent } from "../redemption-list/redemption-list.component"; 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 RedemptionService from '../../shared/services/redemption.service';
import { Observable, of } from 'rxjs'; import { AsyncPipe } from '@angular/common';
import Redemption from '../../shared/models/redemption'; import TwitchRedemptionService from '../../shared/services/twitch-redemption.service';
import { ActivatedRoute } from '@angular/router'; import RedeemableActionService from '../../shared/services/redeemable-action.service';
@Component({ @Component({
selector: 'redemptions', selector: 'redemptions',
imports: [RedemptionListComponent], imports: [
AsyncPipe,
RedemptionListComponent
],
templateUrl: './redemptions.component.html', templateUrl: './redemptions.component.html',
styleUrl: './redemptions.component.scss' styleUrl: './redemptions.component.scss'
}) })
export class RedemptionsComponent implements OnInit { export class RedemptionsComponent {
client = inject(HermesClientService); private readonly twitchRedemptionService = inject(TwitchRedemptionService);
http = inject(HttpClient); private readonly redemptionService = inject(RedemptionService);
route = inject(ActivatedRoute); private readonly actionService = inject(RedeemableActionService);
redemptionService = inject(RedemptionService);
redemptions: Observable<Redemption[]> | undefined;
ngOnInit(): void { redemptions$ = this.redemptionService.changes$;
twitchRedemptions$ = this.twitchRedemptionService.fetch();
} actions$ = this.actionService.changes$;
} }

View File

@ -11,7 +11,7 @@
<mat-autocomplete #auto="matAutocomplete" <mat-autocomplete #auto="matAutocomplete"
[displayWith]="displayFn" [displayWith]="displayFn"
(optionSelected)="select($event.option.value)"> (optionSelected)="select($event.option.value)">
@for (redemption of filteredRedemptions; track redemption.id) { @for (redemption of filteredRedemptions; track redemption.title) {
<mat-option [value]="redemption">{{redemption.title}}</mat-option> <mat-option [value]="redemption">{{redemption.title}}</mat-option>
} }
</mat-autocomplete> </mat-autocomplete>

View File

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

View File

@ -2,5 +2,6 @@ export default interface RedeemableAction {
user_id: string; user_id: string;
name: string; name: string;
type: string; type: string;
has_message: boolean;
data: any; data: any;
} }

View File

@ -0,0 +1,13 @@
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'excludeById',
})
export class ExcludeByIdPipe implements PipeTransform {
transform(values: any[] | null, id: string): any[] | null {
if (!values) {
return values;
}
return values.filter(value => value.id != id);
}
}

View File

@ -1,5 +1,5 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import EventService from '../EventService'; import EventService from '../EventService';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
@ -7,16 +7,17 @@ import { environment } from '../../../../environments/environment';
providedIn: 'root' providedIn: 'root'
}) })
export class ApiAuthenticationService { export class ApiAuthenticationService {
private readonly http = inject(HttpClient);
private readonly events = inject(EventService);
private authenticated: boolean; private authenticated: boolean;
private user: any; private user: any;
private lastCheck: Date; private lastCheck: Date;
constructor(private http: HttpClient, private events: EventService) { constructor() {
this.authenticated = false; this.authenticated = false;
this.user = null; this.user = null;
this.lastCheck = new Date(); this.lastCheck = new Date();
this.events.listen('impersonation', _ => this.update());
} }
isAuthenticated() { isAuthenticated() {
@ -35,19 +36,21 @@ export class ApiAuthenticationService {
return this.user?.impersonation?.name; return this.user?.impersonation?.name;
} }
getUserId() {
return this.user?.id;
}
getUsername() { getUsername() {
return this.user?.name; return this.user?.name;
} }
logout() { logout() {
localStorage.removeItem('jwt'); this.updateAuthenticated(null, false, null);
this.updateAuthenticated(false, null);
} }
update() { update(jwt: string | null) {
const jwt = localStorage.getItem('jwt');
if (!jwt) { if (!jwt) {
this.updateAuthenticated(false, null); this.updateAuthenticated(null, false, null);
return; return;
} }
@ -57,22 +60,27 @@ export class ApiAuthenticationService {
}, },
withCredentials: true withCredentials: true
}).subscribe({ }).subscribe({
next: (data: any) => this.updateAuthenticated(data?.authenticated, data?.user), next: (data: any) => this.updateAuthenticated(jwt, data?.authenticated, data?.user),
error: () => this.updateAuthenticated(false, null) error: () => this.updateAuthenticated(jwt, false, null)
}); });
} }
private updateAuthenticated(authenticated: boolean, user: any) { private updateAuthenticated(jwt: string | null, authenticated: boolean, user: any) {
const previous = this.authenticated; const previous = this.authenticated;
this.user = user; this.user = user;
this.authenticated = authenticated; this.authenticated = authenticated;
this.lastCheck = new Date(); this.lastCheck = new Date();
if (jwt) {
localStorage.setItem('jwt', jwt);
} else {
localStorage.removeItem('jwt');
}
if (previous != authenticated) { if (previous != authenticated) {
if (authenticated) { if (authenticated) {
this.events.emit('login', null); this.events.emit('login', null);
} else { } else {
localStorage.removeItem('jwt');
this.events.emit('logoff', null); this.events.emit('logoff', null);
} }
} }

View File

@ -2,7 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { HermesClientService } from '../../hermes-client.service'; import { HermesClientService } from '../../hermes-client.service';
import EventService from './EventService'; import EventService from './EventService';
import { Permission } from '../models/permission'; import { Permission } from '../models/permission';
import { map, Observable, of } from 'rxjs'; import { filter, map, merge, Observable, of, startWith } from 'rxjs';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -12,6 +12,7 @@ export class PermissionService {
private readonly events = inject(EventService); private readonly events = inject(EventService);
private data: Permission[] = []; private data: Permission[] = [];
private loaded = false; private loaded = false;
changes$: Observable<any>;
create$: Observable<any> | undefined; create$: Observable<any> | undefined;
update$: Observable<any> | undefined; update$: Observable<any> | undefined;
delete$: Observable<any> | undefined; delete$: Observable<any> | undefined;
@ -20,6 +21,12 @@ export class PermissionService {
this.create$ = this.client.filterByRequestType('create_group_permission'); this.create$ = this.client.filterByRequestType('create_group_permission');
this.update$ = this.client.filterByRequestType('update_group_permission'); this.update$ = this.client.filterByRequestType('update_group_permission');
this.delete$ = this.client.filterByRequestType('delete_group_permission'); this.delete$ = this.client.filterByRequestType('delete_group_permission');
this.changes$ = merge<any>(this.create$, this.update$, this.delete$)
.pipe(
startWith(null),
filter(d => !d.error),
map(_ => this.data.slice()),
);
this.create$?.subscribe(d => { this.create$?.subscribe(d => {
if (d.error) { if (d.error) {

View File

@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HermesClientService } from '../../hermes-client.service'; import { HermesClientService } from '../../hermes-client.service';
import { map, Observable, of } from 'rxjs'; import { map, merge, Observable, of, startWith } from 'rxjs';
import RedeemableAction from '../models/redeemable-action'; import RedeemableAction from '../models/redeemable-action';
import EventService from './EventService'; import EventService from './EventService';
@ -10,8 +10,9 @@ import EventService from './EventService';
export default class RedeemableActionService { export default class RedeemableActionService {
private readonly client = inject(HermesClientService); private readonly client = inject(HermesClientService);
private readonly events = inject(EventService); private readonly events = inject(EventService);
private data: RedeemableAction[] = [] private data: RedeemableAction[] = [];
private loaded = false; private loaded = false;
changes$: Observable<any>;
create$: Observable<any> | undefined; create$: Observable<any> | undefined;
update$: Observable<any> | undefined; update$: Observable<any> | undefined;
delete$: Observable<any> | undefined; delete$: Observable<any> | undefined;
@ -21,6 +22,11 @@ export default class RedeemableActionService {
this.create$ = this.client.filterByRequestType('create_redeemable_action'); this.create$ = this.client.filterByRequestType('create_redeemable_action');
this.update$ = this.client.filterByRequestType('update_redeemable_action'); this.update$ = this.client.filterByRequestType('update_redeemable_action');
this.delete$ = this.client.filterByRequestType('delete_redeemable_action'); this.delete$ = this.client.filterByRequestType('delete_redeemable_action');
this.changes$ = merge<any>(this.create$, this.update$, this.delete$)
.pipe(
startWith(null),
map(_ => this.data.slice()),
);
this.create$?.subscribe(d => this.data.push(d.data)); this.create$?.subscribe(d => this.data.push(d.data));
this.update$?.subscribe(d => { this.update$?.subscribe(d => {

View File

@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import Redemption from '../models/redemption'; import Redemption from '../models/redemption';
import { HermesClientService } from '../../hermes-client.service'; import { HermesClientService } from '../../hermes-client.service';
import { map, Observable, of } from 'rxjs'; import { map, merge, Observable, of, startWith } from 'rxjs';
import EventService from './EventService'; import EventService from './EventService';
@Injectable({ @Injectable({
@ -12,6 +12,7 @@ export default class RedemptionService {
private readonly events = inject(EventService); private readonly events = inject(EventService);
private data: Redemption[] = [] private data: Redemption[] = []
private loaded = false; private loaded = false;
changes$: Observable<any>;
create$: Observable<any> | undefined; create$: Observable<any> | undefined;
update$: Observable<any> | undefined; update$: Observable<any> | undefined;
delete$: Observable<any> | undefined; delete$: Observable<any> | undefined;
@ -20,6 +21,11 @@ export default class RedemptionService {
this.create$ = this.client.filterByRequestType('create_redemption'); this.create$ = this.client.filterByRequestType('create_redemption');
this.update$ = this.client.filterByRequestType('update_redemption'); this.update$ = this.client.filterByRequestType('update_redemption');
this.delete$ = this.client.filterByRequestType('delete_redemption'); this.delete$ = this.client.filterByRequestType('delete_redemption');
this.changes$ = merge<any>(this.create$, this.update$, this.delete$)
.pipe(
startWith(null),
map(_ => this.data.slice()),
);
this.create$?.subscribe(d => this.data.push(d.data)); this.create$?.subscribe(d => this.data.push(d.data));
this.update$?.subscribe(d => { this.update$?.subscribe(d => {

View File

@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HermesClientService } from '../../hermes-client.service'; import { HermesClientService } from '../../hermes-client.service';
import { first, map, Observable, of } from 'rxjs'; import { map, Observable, of } from 'rxjs';
import EventService from './EventService'; import EventService from './EventService';
import { Filter } from '../models/filter'; import { Filter } from '../models/filter';
@ -25,9 +25,9 @@ export default class TtsFilterService {
this.update$?.subscribe(d => { this.update$?.subscribe(d => {
const filter = this.data.find(r => r.id == d.data.id); const filter = this.data.find(r => r.id == d.data.id);
if (filter) { if (filter) {
filter.search = d.data.action_name; filter.search = d.data.search;
filter.replace = d.data.redemption_id; filter.replace = d.data.replace;
filter.flag = d.data.order; filter.flag = d.data.flag;
} }
}); });
this.delete$?.subscribe(d => this.data = this.data.filter(r => r.id != d.request.data.id)); this.delete$?.subscribe(d => this.data = this.data.filter(r => r.id != d.request.data.id));

View File

@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import TwitchRedemption from '../models/twitch-redemption'; import TwitchRedemption from '../models/twitch-redemption';
import { catchError, EMPTY, Observable, of } from 'rxjs'; import { catchError, EMPTY, of } from 'rxjs';
import EventService from './EventService'; import EventService from './EventService';
@Injectable({ @Injectable({
@ -11,6 +11,7 @@ import EventService from './EventService';
export default class TwitchRedemptionService { export default class TwitchRedemptionService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly events = inject(EventService); private readonly events = inject(EventService);
private twitchRedemptions: TwitchRedemption[] = []; private twitchRedemptions: TwitchRedemption[] = [];
private loaded = false; private loaded = false;

View File

@ -0,0 +1,18 @@
import TwitchRedemption from "../models/twitch-redemption";
import { toDictStringKeyed } from "../utils/array.transformer";
export function toTwitchRedemptionDict(values: TwitchRedemption[] | null): { [key: string]: TwitchRedemption } {
if (!values) {
return {};
}
return toDictStringKeyed(values, r => r.id, r => r);
}
export function toTwitchRedemptionArray(values: { [id: string]: TwitchRedemption } | null): TwitchRedemption[] {
if (!values) {
return [];
}
return Object.values(values);
}

View File

@ -0,0 +1,7 @@
export function toDictStringKeyed<T, V>(values: T[], keyGetter: { (k: T): string }, valueGetter: { (v: T): V }): { [key: string]: V } {
return Object.assign({}, ...values.map((t: T) => ({ [keyGetter(t)]: valueGetter(t) })))
}
export function toDictNumberKeyed<T, V>(values: T[], keyGetter: { (k: T): number }, valueGetter: { (v: T): V }): { [key: number]: V } {
return Object.assign({}, ...values.map((t: T) => ({ [keyGetter(t)]: valueGetter(t) })))
}

View File

@ -54,12 +54,12 @@
<mat-card-actions> <mat-card-actions>
<button mat-button <button mat-button
class="neutral" class="neutral"
(click)="onCancelClick()" (click)="cancel()"
[disabled]="waitForResponse"> [disabled]="waitForResponse">
<mat-icon>cancel</mat-icon> Cancel</button> <mat-icon>cancel</mat-icon> Cancel</button>
<button mat-button <button mat-button
class="confirm" class="confirm"
(click)="onSaveClick()" (click)="save()"
[disabled]="!forms.dirty || forms.invalid || waitForResponse"> [disabled]="!forms.dirty || forms.invalid || waitForResponse">
<mat-icon>save</mat-icon>Save</button> <mat-icon>save</mat-icon>Save</button>
</mat-card-actions> </mat-card-actions>

View File

@ -29,9 +29,8 @@ import { MatIconModule } from '@angular/material/icon';
}) })
export class FilterItemEditComponent { export class FilterItemEditComponent {
private readonly client = inject(HermesClientService); private readonly client = inject(HermesClientService);
private readonly dialogRef = inject(MatDialogRef<FilterItemEditComponent>); private readonly dialogRef = inject(MatDialogRef<FilterItemEditComponent>);
readonly data = inject<Filter>(MAT_DIALOG_DATA); private readonly data = inject<Filter>(MAT_DIALOG_DATA);
readonly regexOptions = [ readonly regexOptions = [
{ {
@ -52,8 +51,8 @@ export class FilterItemEditComponent {
}, },
]; ];
readonly searchControl = new FormControl(this.data.search, [Validators.required]); readonly searchControl = new FormControl<string>(this.data.search, [Validators.required]);
readonly replaceControl = new FormControl(this.data.replace); readonly replaceControl = new FormControl<string>(this.data.replace);
readonly flagControl = new FormControl<string[]>(this.optionsSelected); readonly flagControl = new FormControl<string[]>(this.optionsSelected);
readonly forms = new FormGroup({ readonly forms = new FormGroup({
search: this.searchControl, search: this.searchControl,
@ -70,7 +69,7 @@ export class FilterItemEditComponent {
return this.regexOptions.filter(o => (flag & o.flag) > 0).map(o => o.name); return this.regexOptions.filter(o => (flag & o.flag) > 0).map(o => o.name);
} }
onSaveClick(): void { save(): void {
if (!this.forms.dirty || this.forms.invalid || this.waitForResponse) if (!this.forms.dirty || this.forms.invalid || this.waitForResponse)
return; return;
@ -110,7 +109,7 @@ export class FilterItemEditComponent {
} }
} }
onCancelClick(): void { cancel(): void {
this.dialogRef.close(); this.dialogRef.close();
} }

View File

@ -1,12 +1,12 @@
<ul> <ul>
<li> <li>
<span> <span>
{{item.search}} {{item().search}}
</span> </span>
</li> </li>
<li> <li>
<span> <span>
{{item.replace}} {{item().replace}}
</span> </span>
</li> </li>
<li> <li>
@ -14,7 +14,7 @@
<button mat-menu-item <button mat-menu-item
(click)="openDialog()">Edit</button> (click)="openDialog()">Edit</button>
<button mat-menu-item <button mat-menu-item
(click)="onDelete.emit(item)">Delete</button> (click)="delete()">Delete</button>
</mat-menu> </mat-menu>
<button mat-icon-button <button mat-icon-button

View File

@ -1,5 +1,5 @@
import { Component, EventEmitter, inject, Input, OnInit, Output } from '@angular/core'; import { Component, inject, input } from '@angular/core';
import { Filter, FilterFlag } from '../../shared/models/filter'; import { Filter } from '../../shared/models/filter';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
@ -11,36 +11,28 @@ import { HermesClientService } from '../../hermes-client.service';
@Component({ @Component({
selector: 'tts-filter-item', selector: 'tts-filter-item',
standalone: true, standalone: true,
imports: [MatButtonModule, MatCardModule, MatMenuModule, MatIconModule], imports: [
MatButtonModule,
MatCardModule,
MatMenuModule,
MatIconModule
],
templateUrl: './filter-item.component.html', templateUrl: './filter-item.component.html',
styleUrl: './filter-item.component.scss' styleUrl: './filter-item.component.scss'
}) })
export class FilterItemComponent implements OnInit { export class FilterItemComponent {
@Input() item: Filter = { id: "", user_id: "", search: "", replace: "", flag: FilterFlag.None }; item = input.required<Filter>();
@Output() onDelete = new EventEmitter<Filter>();
readonly client = inject(HermesClientService);
readonly dialog = inject(MatDialog);
private loaded = false;
private readonly client = inject(HermesClientService);
private readonly dialog = inject(MatDialog);
ngOnInit(): void { delete() {
this.loaded = true; this.client.deleteTTSFilter(this.item().id);
} }
openDialog(): void { openDialog(): void {
if (!this.loaded) this.dialog.open(FilterItemEditComponent, {
return; data: { id: this.item().id, search: this.item().search, replace: this.item().replace, flag: this.item().flag },
const dialogRef = this.dialog.open(FilterItemEditComponent, {
data: { id: this.item.id, search: this.item.search, replace: this.item.replace, flag: this.item.flag },
});
dialogRef.afterClosed().subscribe((result: Filter) => {
if (result) {
this.item.search = result.search;
this.item.replace = result.replace;
this.item.flag = result.flag;
}
}); });
} }
} }

View File

@ -7,10 +7,9 @@
<li></li> <li></li>
</ul> </ul>
</li> </li>
@for (filter of filters; track $index) { @for (filter of filters(); track $index) {
<li> <li>
<tts-filter-item [item]="filter" <tts-filter-item [item]="filter" />
(onDelete)="deleteFilter($event)" />
</li> </li>
} }
</ul> </ul>

View File

@ -1,4 +1,4 @@
import { Component, inject, Input } from '@angular/core'; import { Component, inject, input, Input } from '@angular/core';
import { FilterItemComponent } from '../filter-item/filter-item.component'; import { FilterItemComponent } from '../filter-item/filter-item.component';
import { Filter } from '../../shared/models/filter'; import { Filter } from '../../shared/models/filter';
import { HermesClientService } from '../../hermes-client.service'; import { HermesClientService } from '../../hermes-client.service';
@ -11,10 +11,7 @@ import { HermesClientService } from '../../hermes-client.service';
styleUrl: './filter-list.component.scss' styleUrl: './filter-list.component.scss'
}) })
export class FilterListComponent { export class FilterListComponent {
@Input() filters: Filter[] = []; private readonly client = inject(HermesClientService);
client = inject(HermesClientService);
deleteFilter(e: any): void { filters = input.required<Filter[]>();
this.client.deleteTTSFilter(e.id);
}
} }

View File

@ -1,11 +1,9 @@
import { Component, inject, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { Component, inject, OnDestroy } from '@angular/core';
import { FilterListComponent } from "../filter-list/filter-list.component"; import { FilterListComponent } from "../filter-list/filter-list.component";
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { HermesClientService } from '../../hermes-client.service';
import { Filter, FilterFlag } from '../../shared/models/filter'; import { Filter, FilterFlag } from '../../shared/models/filter';
import { isPlatformBrowser } from '@angular/common'; import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { FilterItemEditComponent } from '../filter-item-edit/filter-item-edit.component'; import { FilterItemEditComponent } from '../filter-item-edit/filter-item-edit.component';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import TtsFilterService from '../../shared/services/tts-filter.service'; import TtsFilterService from '../../shared/services/tts-filter.service';
@ -14,84 +12,37 @@ import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'filters', selector: 'filters',
standalone: true, standalone: true,
imports: [FilterListComponent, MatButtonModule, MatIconModule], imports: [
FilterListComponent,
MatButtonModule,
MatIconModule
],
templateUrl: './filters.component.html', templateUrl: './filters.component.html',
styleUrl: './filters.component.scss' styleUrl: './filters.component.scss'
}) })
export class FiltersComponent implements OnInit, OnDestroy { export class FiltersComponent implements OnDestroy {
private readonly filterService = inject(TtsFilterService); private readonly filterService = inject(TtsFilterService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly subscriptions: Subscription[] = []; private readonly subscriptions: (Subscription | undefined)[] = [];
private isBrowser: boolean;
readonly dialog = inject(MatDialog); readonly dialog = inject(MatDialog);
items: Filter[];
items: Filter[] = [];
constructor(private client: HermesClientService, private router: Router, @Inject(PLATFORM_ID) private platformId: Object) { constructor() {
this.isBrowser = isPlatformBrowser(this.platformId); this.route.data.subscribe(data => this.items = data['filters'] || []);
this.items = [];
this.route.data.subscribe(data => { this.subscriptions.push(this.filterService.delete$?.subscribe(d => this.items = this.items.filter(a => a.id != d.request.data.id)));
if (!data['filters'])
return;
this.items = [...data['filters']];
});
let subscription = this.filterService.create$?.subscribe(d => {
if (d.error || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id))
return;
this.items.push(d.data);
});
if (subscription)
this.subscriptions.push(subscription);
subscription = this.filterService.update$?.subscribe(d => {
if (d.error || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id))
return;
const filter = this.items.find(f => f.id == d.data.id);
if (filter) {
filter.search = d.data.search;
filter.replace = d.data.replace;
filter.flag = d.data.flag || 0;
}
});
if (subscription)
this.subscriptions.push(subscription);
subscription = this.filterService.delete$?.subscribe(d => {
if (d.error)
return;
this.items = this.items.filter(a => a.id != d.request.data.id);
});
if (subscription)
this.subscriptions.push(subscription);
}
ngOnInit(): void {
if (!this.isBrowser)
return;
if (!this.client.logged_in) {
this.router.navigate(["tts-login"]);
return;
}
} }
ngOnDestroy() { ngOnDestroy() {
this.subscriptions.forEach(s => s.unsubscribe()); this.subscriptions.filter(s => !!s).forEach(s => s.unsubscribe());
} }
openDialog(): void { openDialog(): void {
const dialogRef = this.dialog.open(FilterItemEditComponent, { this.dialog.open(FilterItemEditComponent, {
data: { id: '', user_id: '', search: '', replace: '', flag: FilterFlag.None }, data: { id: '', user_id: '', search: '', replace: '', flag: FilterFlag.None },
}); });
dialogRef.afterClosed().subscribe((result: Filter) => {
if (result)
this.items.push(result);
});
} }
} }

View File

@ -52,9 +52,7 @@ export class TwitchAuthCallbackComponent implements OnInit, OnDestroy {
this.http.post(environment.API_HOST + '/auth/twitch/callback', { code, scope, state }) this.http.post(environment.API_HOST + '/auth/twitch/callback', { code, scope, state })
.subscribe({ .subscribe({
next: async (response: any) => { next: async (response: any) => {
console.log('twitch api callback response:', response); this.auth.update(response.token);
localStorage.setItem('jwt', response.token);
this.auth.update();
}, },
error: async () => await this.router.navigate(['login'], { error: async () => await this.router.navigate(['login'], {
queryParams: { queryParams: {

View File

@ -1,6 +1,13 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
ul { ul {
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 500px;
overflow: hidden;
@include mat.all-component-densities(-5); @include mat.all-component-densities(-5);
@include mat.form-field-overrides(( @include mat.form-field-overrides((
@ -8,13 +15,6 @@ ul {
outlined-focus-label-text-color: rgb(155, 57, 194), outlined-focus-label-text-color: rgb(155, 57, 194),
outlined-focus-outline-color: rgb(155, 57, 194), outlined-focus-outline-color: rgb(155, 57, 194),
)); ));
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 500px;
overflow: hidden;
} }
ul li { ul li {

View File

@ -7,6 +7,10 @@
<base href="/"> <base href="/">
<meta name="viewport" <meta name="viewport"
content="width=device-width, initial-scale=1"> content="width=device-width, initial-scale=1">
<meta name="robots"
content="noindex,nofollow">
<meta name="googlebot"
content="noindex,nofollow">
<link rel="icon" <link rel="icon"
type="image/x-icon" type="image/x-icon"
href="favicon.ico"> href="favicon.ico">

View File

@ -21,9 +21,9 @@ main {
html, html,
body, body,
div.below-topbar,
main, main,
content, content {
sidebar {
background-color: var(--mat-sys-background); background-color: var(--mat-sys-background);
color: var(--mat-sys-on-background); color: var(--mat-sys-on-background);
} }