event subscriptions

This commit is contained in:
toni
2026-03-15 15:39:45 +01:00
parent 29c6828dd1
commit 2620538a2c
26 changed files with 1028 additions and 214 deletions
+5
View File
@@ -70,5 +70,10 @@ export const routes: Routes = [
),
canActivate: [requireRoles([Claim.Memberadmin])],
},
{
path: 'events',
loadComponent: () =>
import('./components/events/events').then(mod => mod.Events),
},
{ path: '**', component: MissingPage },
];
@@ -0,0 +1,65 @@
<h1>Gesammelte Veranstaltungen</h1>
<p>
<button matButton="filled" (click)="subscribe()">Abbonieren</button>
<button matButton="filled" class="secondary-btn" (click)="unsubscribe()">
Abmelden
</button>
</p>
<div class="table-container">
<table mat-table [dataSource]="datasource" matSort>
<ng-container matColumnDef="source">
<th mat-header-cell mat-sort-header *matHeaderCellDef>
Gefunden bei
</th>
<td mat-cell *matCellDef="let element">
<a [href]="element.source"
>{{truncateSource(element.source)}}</a
>
</td>
</ng-container>
<ng-container matColumnDef="url">
<th mat-header-cell mat-sort-header *matHeaderCellDef>Link</th>
<td mat-cell *matCellDef="let element">
<a
matButton
[href]="element.url"
target="_blank"
[ellipsis]="80"
>{{element.url}}</a
>
</td>
</ng-container>
<ng-container matColumnDef="eventDate">
<th mat-header-cell mat-sort-header *matHeaderCellDef>
Ausrichtungsdatum
</th>
<td mat-cell *matCellDef="let element">{{element.eventDate}}</td>
</ng-container>
<ng-container matColumnDef="title">
<th mat-header-cell mat-sort-header *matHeaderCellDef>Titel</th>
<td mat-cell *matCellDef="let element">{{element.title}}</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell mat-sort-header *matHeaderCellDef>
Beschreibung
</th>
<td mat-cell *matCellDef="let element">{{element.description}}</td>
</ng-container>
<ng-container matColumnDef="attachedFiles">
<th mat-header-cell *matHeaderCellDef>Anhänge</th>
<td mat-cell *matCellDef="let element">
@for(attachment of element.attachedFiles; track attachment.id){
<a matButton="outlined" [href]="attachment.url" target="_blank"
>{{attachment.title??'?'}}</a
>
}
</td>
</ng-container>
<tr
mat-header-row
*matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>
@@ -0,0 +1,12 @@
:host {
display: flex;
flex-flow: column;
height: 100%;
// overflow: auto;
}
.table-container {
overflow: auto;
border: 1px solid var(--mat-sys-outline);
min-height: 400px;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Events } from './events';
describe('Events', () => {
let component: Events;
let fixture: ComponentFixture<Events>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Events]
})
.compileComponents();
fixture = TestBed.createComponent(Events);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,79 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { parse } from 'date-fns';
import { Ellipsis } from '../../directives/ellipsis/ellipsis';
import * as api from '../../generated-api/api';
import { PushService } from '../../services/push/pushService';
@Component({
selector: 'app-events',
imports: [MatTableModule, MatSortModule, MatButtonModule, Ellipsis],
templateUrl: './events.html',
styleUrl: './events.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Events implements OnInit {
private readonly pushService = inject(PushService);
protected readonly datasource = new MatTableDataSource<api.FoundEventDto>();
protected readonly sort = viewChild.required(MatSort);
protected readonly displayedColumns = [
'eventDate',
'title',
'description',
'attachedFiles',
'url',
'source',
];
public async ngOnInit(): Promise<void> {
this.datasource.sort = this.sort();
const originalSortingAccessor = this.datasource.sortingDataAccessor;
this.datasource.sortingDataAccessor = (item, property) => {
if (property != 'eventDate') {
return originalSortingAccessor(item, property);
}
if (!item.eventDate) {
return NaN;
}
const parts = item.eventDate.split('-');
if (parts.length == 1) {
return parse(parts[0]!, 'dd.MM.yyyy', new Date()).valueOf();
}
if (parts.length == 2) {
const p2 = parts[1].split('.');
p2.splice(0, 1, parts[0]);
return parse(p2.join('.'), 'dd.MM.yyyy', new Date()).valueOf();
}
console.warn('unknown date format:', property);
return originalSortingAccessor(item, property);
};
const data = await api.getFutureEvents();
this.datasource.data = data;
}
protected truncateSource(sourceUrl: string): string {
if (!URL.canParse(sourceUrl)) {
return sourceUrl;
}
const url = new URL(sourceUrl);
return url.hostname;
}
protected async unsubscribe() {
await this.pushService.resetSubscriptions();
}
protected async subscribe() {
this.pushService.subscribeToEvents();
}
}
@@ -5,16 +5,24 @@
Mitgliederliste
</button>
</mat-list-item>
<mat-list-item [claimGuard]="Claim.Useradmin">
<mat-list-item>
<button
matButton="filled"
class="tertiary-btn"
[routerLink]="['/events']">
Veranstaltungen
</button>
</mat-list-item>
<!-- <mat-list-item [claimGuard]="Claim.Useradmin">
<button matButton="filled" color="warn" [routerLink]="['/manageUsers']">
Benutzer Verwalten
</button>
</mat-list-item>
<mat-list-item [claimGuard]="Claim.Memberadmin">
</mat-list-item> -->
<!-- <mat-list-item [claimGuard]="Claim.Memberadmin">
<button matButton="filled" color="primary" [routerLink]="['/exam']">
Prüfungsanmeldung
</button>
</mat-list-item>
</mat-list-item> -->
<mat-list-item>
<button
matButton="outlined"
@@ -0,0 +1,8 @@
import { Ellipsis } from './ellipsis';
describe('Ellipsis', () => {
it('should create an instance', () => {
const directive = new Ellipsis();
expect(directive).toBeTruthy();
});
});
@@ -0,0 +1,53 @@
import {
AfterViewInit,
Directive,
effect,
ElementRef,
inject,
input,
signal,
WritableSignal,
} from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
@Directive({
selector: '[ellipsis]',
hostDirectives: [MatTooltip],
})
export class Ellipsis implements AfterViewInit {
// private readonly templateRef = inject(TemplateRef<any>);
// private readonly viewContainer = inject(ViewContainerRef);
private readonly elementRef = inject<ElementRef<HTMLElement>>(
ElementRef<HTMLElement>,
);
private readonly tooltip = inject(MatTooltip);
private readonly originalText: WritableSignal<string>;
public readonly ellipsis = input.required<number>();
public constructor() {
this.originalText = signal(this.elementRef.nativeElement.innerHTML);
effect(() => {
const length = this.ellipsis();
const originalText = this.originalText();
this.tooltip.message = originalText;
if (length < 0) {
console.warn('Ellipsis Pipe with negative length', length);
this.elementRef.nativeElement.innerHTML = '';
return;
}
if (length < 4) {
this.elementRef.nativeElement.innerHTML = '.'.repeat(length);
return;
}
if (originalText.length > length) {
this.elementRef.nativeElement.innerHTML = `${originalText.substring(0, length - 3)}...`;
return;
}
});
}
public ngAfterViewInit(): void {
const originalText = this.elementRef.nativeElement.innerText;
this.originalText.set(originalText);
}
}
@@ -0,0 +1,29 @@
type USVString = string;
type DOMString = string;
type DOMTimeStamp = number;
export interface NotificationAction {
action: string;
title: string;
icon: string;
}
export interface AngularPushPayload {
notification: {
actions?: NotificationAction[];
badge?: USVString;
body?: DOMString;
data?: any;
dir?: 'auto' | 'ltr' | 'rtl';
icon?: USVString;
image?: USVString;
lang?: DOMString;
renotify?: boolean;
requireInteraction?: boolean;
silent?: boolean;
tag?: DOMString;
timestamp?: DOMTimeStamp;
title: DOMString;
vibrate?: number[];
};
}
@@ -2,69 +2,71 @@ import { inject, Injectable } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { v7 as uuidv7 } from 'uuid';
import * as api from '../../generated-api/api';
import VAPID from './vapid.json' with { type: "json" };
import VAPID from './vapid.json' with { type: 'json' };
const SUBSCRIPTION_CLIENT_ID="subscriptionClientId";
const SUBSCRIPTION_CLIENT_ID = 'subscriptionClientId';
@Injectable({providedIn: 'root'})
export class PushService{
private readonly swPush=inject(SwPush);
@Injectable({ providedIn: 'root' })
export class PushService {
private readonly swPush = inject(SwPush);
private arrayBufferToString(buffer: ArrayBuffer|null){
if(buffer==null){
private arrayBufferToString(buffer: ArrayBuffer | null) {
if (buffer == null) {
return '';
}
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.length;
for(let i = 0; i< len; i++){
binary+=String.fromCharCode(bytes[i]);
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
public async subscribeToEvents(){
try{
public async subscribeToEvents() {
try {
const subscription = await this.swPush.requestSubscription({
serverPublicKey: VAPID.publicKey
serverPublicKey: VAPID.publicKey,
});
const decoder = new TextDecoder();
const previousCliendId = localStorage.getItem(SUBSCRIPTION_CLIENT_ID);
if(previousCliendId){
try{
const previousCliendId = localStorage.getItem(
SUBSCRIPTION_CLIENT_ID,
);
if (previousCliendId) {
try {
await api.clearSubscription(previousCliendId);
console.log("previous subscription cleared");
}catch{}
console.log('previous subscription cleared');
} catch {}
}
const newClientId= uuidv7();
const newClientId = uuidv7();
await api.addSubscription({
endpoint: subscription.endpoint,
keys: {
auth: this.arrayBufferToString(subscription.getKey('auth')),
p256dh: this.arrayBufferToString(subscription.getKey('p256dh')),
p256dh: this.arrayBufferToString(
subscription.getKey('p256dh'),
),
},
expirationTime: subscription.expirationTime,
topic: api.Topic.Test,
topic: api.Topic.Events,
topicConfiguration: null,
clientId: newClientId,
});
localStorage.setItem(SUBSCRIPTION_CLIENT_ID, newClientId);
}catch(error){
console.error("Subscribing failed: ", error);
} catch (error) {
console.error('Subscribing failed: ', error);
}
}
public async resetSubscriptions(){
public async resetSubscriptions() {
await this.swPush.unsubscribe();
await api.resetSubscriptions();
}
public async publishTestMessage(){
public async publishTestMessage() {
await api.publishTestMessage();
}
}