diff options
Diffstat (limited to 'overlays/spm/frontend/src')
| -rw-r--r-- | overlays/spm/frontend/src/app/app-routing.module.ts | 11 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/app.component.html | 28 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/app.component.sass | 2 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/app.component.ts | 89 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/app.module.ts | 14 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/color-scheme.service.ts | 62 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts | 16 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/spm/spm.component.html | 34 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/spm/spm.component.sass | 13 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/spm/spm.component.ts | 181 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/app/spm/spm.module.ts | 31 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/custom-theme.scss | 2 | ||||
| -rw-r--r-- | overlays/spm/frontend/src/styles.sass | 1 |
13 files changed, 388 insertions, 96 deletions
diff --git a/overlays/spm/frontend/src/app/app-routing.module.ts b/overlays/spm/frontend/src/app/app-routing.module.ts index 2da97cea..bffe8c4d 100644 --- a/overlays/spm/frontend/src/app/app-routing.module.ts +++ b/overlays/spm/frontend/src/app/app-routing.module.ts | |||
| @@ -1,17 +1,14 @@ | |||
| 1 | import { NgModule } from '@angular/core'; | 1 | import { NgModule } from '@angular/core'; |
| 2 | import { RouterModule, Routes } from '@angular/router'; | 2 | import { RouterModule, Routes, PreloadAllModules } from '@angular/router'; |
| 3 | |||
| 4 | import { SpmComponent } from './spm/spm.component'; | ||
| 5 | import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; | ||
| 6 | 3 | ||
| 7 | const routes: Routes = [ | 4 | const routes: Routes = [ |
| 8 | { path: 'spm', component: SpmComponent }, | 5 | { path: 'spm', loadChildren: () => import('./spm/spm.module').then(m => m.SpmModule) }, |
| 9 | { path: '', redirectTo: '/spm', pathMatch: 'full' }, | 6 | { path: '', redirectTo: '/spm', pathMatch: 'full' }, |
| 10 | { path: '**', component: PageNotFoundComponent } | 7 | { path: '**', loadChildren: () => import('./page-not-found/page-not-found.module').then(m => m.PageNotFoundModule) } |
| 11 | ]; | 8 | ]; |
| 12 | 9 | ||
| 13 | @NgModule({ | 10 | @NgModule({ |
| 14 | imports: [RouterModule.forRoot(routes)], | 11 | imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })], |
| 15 | exports: [RouterModule] | 12 | exports: [RouterModule] |
| 16 | }) | 13 | }) |
| 17 | export class AppRoutingModule { } | 14 | export class AppRoutingModule { } |
diff --git a/overlays/spm/frontend/src/app/app.component.html b/overlays/spm/frontend/src/app/app.component.html index f9b5b8fc..1a2f88b0 100644 --- a/overlays/spm/frontend/src/app/app.component.html +++ b/overlays/spm/frontend/src/app/app.component.html | |||
| @@ -1,7 +1,21 @@ | |||
| 1 | <mat-toolbar color="primary"> | 1 | <div gdAreas="nav | content" gdRows="min-content 1fr" style="min-height: 100vh"> |
| 2 | <div> | 2 | <mat-toolbar color="primary" gdArea="nav" fxLayout="flex" fxLayoutGap="16px"> |
| 3 | <a mat-button routerLink="/spm" routerLinkActive="active">spm</a> | 3 | <div> |
| 4 | </div> | 4 | <span class="mono" [innerHTML]="apiDomain$ | async"></span> |
| 5 | </mat-toolbar> | 5 | </div> |
| 6 | <!-- The routed views render in the <router-outlet> --> | 6 | <div> |
| 7 | <router-outlet></router-outlet> | 7 | <a mat-button routerLink="/spm" routerLinkActive="active">spm</a> |
| 8 | </div> | ||
| 9 | </mat-toolbar> | ||
| 10 | <!-- The routed views render in the <router-outlet> --> | ||
| 11 | <ng-template #router_loading_indicator> | ||
| 12 | <div gdArea="content" fxLayout="column" fxLayoutAlign="center stretch"> | ||
| 13 | <div fxFlex="none" fxFlexAlign="center"> | ||
| 14 | <mat-spinner></mat-spinner> | ||
| 15 | </div> | ||
| 16 | </div> | ||
| 17 | </ng-template> | ||
| 18 | <div class="content" gdArea="content" *ngIf="!(routerLoading$ | async); else router_loading_indicator"> | ||
| 19 | <router-outlet></router-outlet> | ||
| 20 | </div> | ||
| 21 | </div> | ||
diff --git a/overlays/spm/frontend/src/app/app.component.sass b/overlays/spm/frontend/src/app/app.component.sass index e69de29b..4732ea49 100644 --- a/overlays/spm/frontend/src/app/app.component.sass +++ b/overlays/spm/frontend/src/app/app.component.sass | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | .content | ||
| 2 | padding: 16px | ||
diff --git a/overlays/spm/frontend/src/app/app.component.ts b/overlays/spm/frontend/src/app/app.component.ts index 7c231203..11c0f772 100644 --- a/overlays/spm/frontend/src/app/app.component.ts +++ b/overlays/spm/frontend/src/app/app.component.ts | |||
| @@ -1,6 +1,18 @@ | |||
| 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; | 1 | import { Component, OnInit, OnDestroy, Inject, Renderer2, RendererFactory2 } from '@angular/core'; |
| 2 | import { DOCUMENT } from '@angular/common'; | ||
| 3 | import { MediaMatcher } from '@angular/cdk/layout'; | ||
| 4 | import { LocalStorage } from 'ngx-webstorage'; | ||
| 5 | import { BehaviorSubject, Subscription, Subject } from 'rxjs'; | ||
| 6 | import { Router, RouterEvent, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router'; | ||
| 7 | import { HttpClient, HttpHeaders } from '@angular/common/http'; | ||
| 2 | 8 | ||
| 3 | import { ColorSchemeService } from './color-scheme.service'; | 9 | import { map, filter } from 'rxjs/operators'; |
| 10 | |||
| 11 | type ColorScheme = 'dark' | 'light'; | ||
| 12 | |||
| 13 | export interface DomainResponse { | ||
| 14 | domain: string; | ||
| 15 | } | ||
| 4 | 16 | ||
| 5 | @Component({ | 17 | @Component({ |
| 6 | selector: 'app-root', | 18 | selector: 'app-root', |
| @@ -8,14 +20,81 @@ import { ColorSchemeService } from './color-scheme.service'; | |||
| 8 | styleUrls: ['./app.component.sass'] | 20 | styleUrls: ['./app.component.sass'] |
| 9 | }) | 21 | }) |
| 10 | export class AppComponent implements OnInit, OnDestroy { | 22 | export class AppComponent implements OnInit, OnDestroy { |
| 23 | private renderer: Renderer2; | ||
| 24 | colorSchemeMatcher: MediaQueryList; | ||
| 25 | |||
| 11 | title = 'spm-frontend'; | 26 | title = 'spm-frontend'; |
| 12 | 27 | ||
| 13 | constructor(private colorSchemeService: ColorSchemeService) {} | 28 | public activeColorScheme$: BehaviorSubject<ColorScheme> = new BehaviorSubject<ColorScheme>('dark'); |
| 29 | |||
| 30 | @LocalStorage('prefers-color-scheme') | ||
| 31 | public colorSchemeOverride?: ColorScheme; | ||
| 32 | |||
| 33 | colorSchemeSubscription?: Subscription; | ||
| 34 | |||
| 35 | router: Router; | ||
| 36 | public routerLoading$: Subject<boolean> = new Subject<boolean>(); | ||
| 37 | routerEventSubscription?: Subscription; | ||
| 38 | |||
| 39 | http: HttpClient; | ||
| 40 | apiDomain$: Subject<string> = new Subject<string>(); | ||
| 41 | |||
| 42 | constructor( rendererFactory: RendererFactory2, | ||
| 43 | mediaMatcher: MediaMatcher, | ||
| 44 | @Inject(DOCUMENT) private document: Document, | ||
| 45 | router: Router, | ||
| 46 | http: HttpClient, | ||
| 47 | ) { | ||
| 48 | this.renderer = rendererFactory.createRenderer(null, null); | ||
| 49 | this.colorSchemeMatcher = mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); | ||
| 50 | this.document = document; | ||
| 51 | |||
| 52 | this.colorSchemeListener = this.colorSchemeListener.bind(this); | ||
| 53 | this.colorSchemeMatcher.addEventListener('change', this.colorSchemeListener); | ||
| 54 | this.activeColorScheme$.next(this.getColorScheme()); | ||
| 55 | |||
| 56 | this.router = router; | ||
| 57 | this.http = http; | ||
| 58 | } | ||
| 59 | |||
| 60 | private colorSchemeListener(event: { matches: boolean; }) { | ||
| 61 | this.activeColorScheme$.next(this.getColorScheme(event)); | ||
| 62 | } | ||
| 63 | |||
| 64 | private getColorScheme(event?: { matches: boolean; }): ColorScheme { | ||
| 65 | if (this.colorSchemeOverride) { | ||
| 66 | return this.colorSchemeOverride; | ||
| 67 | } else if (event) { | ||
| 68 | return event.matches ? 'dark' : 'light'; | ||
| 69 | } else { | ||
| 70 | return this.colorSchemeMatcher.matches ? 'dark' : 'light'; | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | updateColorScheme(scheme: ColorScheme) { | ||
| 75 | this.colorSchemeOverride = scheme; | ||
| 76 | this.activeColorScheme$.next(scheme); | ||
| 77 | } | ||
| 14 | 78 | ||
| 15 | ngOnInit() { | 79 | ngOnInit() { |
| 16 | this.colorSchemeService.init(); | 80 | this.colorSchemeSubscription = this.activeColorScheme$.subscribe(scheme => { this.renderer.setAttribute(this.document.body, 'data-color-scheme', scheme); }); |
| 81 | |||
| 82 | this.routerEventSubscription = this.router.events.pipe( | ||
| 83 | filter(event => | ||
| 84 | event instanceof NavigationStart || | ||
| 85 | event instanceof NavigationEnd || | ||
| 86 | event instanceof NavigationCancel || | ||
| 87 | event instanceof NavigationError | ||
| 88 | ), | ||
| 89 | map(event => event instanceof NavigationStart) | ||
| 90 | ).subscribe(this.routerLoading$); | ||
| 91 | |||
| 92 | this.http.get<DomainResponse>('/domain', { headers: new HttpHeaders({ 'Accept': 'application/json' }) }).pipe(map((resp: DomainResponse) => resp.domain)).subscribe(this.apiDomain$); | ||
| 17 | } | 93 | } |
| 18 | ngOnDestroy() { | 94 | ngOnDestroy() { |
| 19 | this.colorSchemeService.destroy(); | 95 | this.colorSchemeMatcher.removeEventListener('change', this.colorSchemeListener); |
| 96 | |||
| 97 | this.colorSchemeSubscription?.unsubscribe(); | ||
| 98 | this.routerEventSubscription?.unsubscribe(); | ||
| 20 | } | 99 | } |
| 21 | } | 100 | } |
diff --git a/overlays/spm/frontend/src/app/app.module.ts b/overlays/spm/frontend/src/app/app.module.ts index 914f73a2..98e5106e 100644 --- a/overlays/spm/frontend/src/app/app.module.ts +++ b/overlays/spm/frontend/src/app/app.module.ts | |||
| @@ -1,30 +1,30 @@ | |||
| 1 | import { NgModule } from '@angular/core'; | 1 | import { NgModule } from '@angular/core'; |
| 2 | import { BrowserModule } from '@angular/platform-browser'; | 2 | import { BrowserModule } from '@angular/platform-browser'; |
| 3 | import { NgxWebstorageModule } from 'ngx-webstorage'; | ||
| 3 | 4 | ||
| 4 | import { AppRoutingModule } from './app-routing.module'; | 5 | import { AppRoutingModule } from './app-routing.module'; |
| 5 | import { AppComponent } from './app.component'; | 6 | import { AppComponent } from './app.component'; |
| 6 | import { SpmComponent } from './spm/spm.component'; | ||
| 7 | import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; | ||
| 8 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; | 7 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; |
| 9 | import { MatIconModule } from '@angular/material/icon'; | ||
| 10 | import { HttpClientModule } from "@angular/common/http"; | 8 | import { HttpClientModule } from "@angular/common/http"; |
| 11 | import { MatToolbarModule } from '@angular/material/toolbar'; | 9 | import { MatToolbarModule } from '@angular/material/toolbar'; |
| 12 | import { MatButtonModule } from '@angular/material/button'; | 10 | import { MatButtonModule } from '@angular/material/button'; |
| 11 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; | ||
| 12 | import { FlexLayoutModule } from "@angular/flex-layout"; | ||
| 13 | 13 | ||
| 14 | @NgModule({ | 14 | @NgModule({ |
| 15 | declarations: [ | 15 | declarations: [ |
| 16 | AppComponent, | 16 | AppComponent, |
| 17 | SpmComponent, | ||
| 18 | PageNotFoundComponent | ||
| 19 | ], | 17 | ], |
| 20 | imports: [ | 18 | imports: [ |
| 21 | BrowserModule, | 19 | BrowserModule, |
| 20 | NgxWebstorageModule.forRoot(), | ||
| 22 | AppRoutingModule, | 21 | AppRoutingModule, |
| 23 | BrowserAnimationsModule, | 22 | BrowserAnimationsModule, |
| 24 | MatIconModule, | ||
| 25 | HttpClientModule, | 23 | HttpClientModule, |
| 26 | MatToolbarModule, | 24 | MatToolbarModule, |
| 27 | MatButtonModule | 25 | MatButtonModule, |
| 26 | MatProgressSpinnerModule, | ||
| 27 | FlexLayoutModule, | ||
| 28 | ], | 28 | ], |
| 29 | providers: [], | 29 | providers: [], |
| 30 | bootstrap: [AppComponent] | 30 | bootstrap: [AppComponent] |
diff --git a/overlays/spm/frontend/src/app/color-scheme.service.ts b/overlays/spm/frontend/src/app/color-scheme.service.ts deleted file mode 100644 index c56e9c23..00000000 --- a/overlays/spm/frontend/src/app/color-scheme.service.ts +++ /dev/null | |||
| @@ -1,62 +0,0 @@ | |||
| 1 | import { Injectable, Renderer2, RendererFactory2 } from '@angular/core'; | ||
| 2 | import { MediaMatcher } from '@angular/cdk/layout'; | ||
| 3 | import { LocalStorage } from 'ngx-webstorage'; | ||
| 4 | |||
| 5 | type ColorScheme = "dark" | "light"; | ||
| 6 | |||
| 7 | @Injectable({ | ||
| 8 | providedIn: 'root' | ||
| 9 | }) | ||
| 10 | export class ColorSchemeService { | ||
| 11 | private renderer: Renderer2; | ||
| 12 | matcher!: MediaQueryList; | ||
| 13 | |||
| 14 | @LocalStorage('prefers-color-scheme') | ||
| 15 | public colorSchemeOverride: ColorScheme; | ||
| 16 | |||
| 17 | public activeColorScheme: BehaviorSubject<ColorScheme> = new BehaviorSubject("light"); | ||
| 18 | |||
| 19 | constructor(rendererFactory: RendererFactory2, | ||
| 20 | mediaMatcher: MediaMatcher | ||
| 21 | ) { | ||
| 22 | // Create new renderer from renderFactory, to make it possible to use renderer2 in a service | ||
| 23 | this.renderer = rendererFactory.createRenderer(null, null); | ||
| 24 | this.matcher = mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); | ||
| 25 | |||
| 26 | this._listener = this._listener.bind(this); | ||
| 27 | } | ||
| 28 | |||
| 29 | init() { | ||
| 30 | this.matcher.addEventListener('change', this._listener); | ||
| 31 | this.load(); | ||
| 32 | } | ||
| 33 | |||
| 34 | _listener(event: { matches: any; }) { | ||
| 35 | this.activeColorScheme.next(event.matches ? 'dark' : 'light'); | ||
| 36 | } | ||
| 37 | |||
| 38 | destroy() { | ||
| 39 | this.matcher.removeEventListener('change', this._listener); | ||
| 40 | } | ||
| 41 | |||
| 42 | _getColorScheme() { | ||
| 43 | if (this.colorSchemeOverride) { | ||
| 44 | this.colorSchemeOverride | ||
| 45 | } else { | ||
| 46 | this.matcher.matches ? 'dark' : 'light' | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | load() { | ||
| 51 | this.activeColorScheme.next(this._getColorScheme()); | ||
| 52 | } | ||
| 53 | |||
| 54 | update(scheme: ColorScheme) { | ||
| 55 | this.colorSchemeOverride = scheme; | ||
| 56 | this.activeColorScheme.next(scheme); | ||
| 57 | } | ||
| 58 | |||
| 59 | currentActive() { | ||
| 60 | return this.colorScheme; | ||
| 61 | } | ||
| 62 | } | ||
diff --git a/overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts b/overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts new file mode 100644 index 00000000..b9d8945c --- /dev/null +++ b/overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | import { NgModule } from '@angular/core'; | ||
| 2 | import { PageNotFoundComponent } from './page-not-found.component'; | ||
| 3 | import { RouterModule } from '@angular/router'; | ||
| 4 | |||
| 5 | @NgModule({ | ||
| 6 | declarations: [PageNotFoundComponent], | ||
| 7 | exports: [PageNotFoundComponent], | ||
| 8 | imports: [ | ||
| 9 | RouterModule.forChild([{ | ||
| 10 | path: '', | ||
| 11 | pathMatch: 'full', | ||
| 12 | component: PageNotFoundComponent | ||
| 13 | }]), | ||
| 14 | ] | ||
| 15 | }) | ||
| 16 | export class PageNotFoundModule {} | ||
diff --git a/overlays/spm/frontend/src/app/spm/spm.component.html b/overlays/spm/frontend/src/app/spm/spm.component.html index d1339d95..5d0e625a 100644 --- a/overlays/spm/frontend/src/app/spm/spm.component.html +++ b/overlays/spm/frontend/src/app/spm/spm.component.html | |||
| @@ -1,3 +1,33 @@ | |||
| 1 | <p>spm works!</p> | 1 | <div id="mail-panel-container" fxLayout="row wrap" style="gap: 16px"> |
| 2 | <ng-template ngFor [ngForOf]="spmMails$ | async | keyvalue: asIsOrder" let-entry> | ||
| 3 | <mat-card> | ||
| 4 | <mat-card-title class="mono" *ngIf="entry.value.state !== 'loading'">{{entry.value.local}}</mat-card-title> | ||
| 5 | <mat-card-subtitle class="mono" *ngIf="entry.value.state !== 'loading'">@{{entry.value.domain}}</mat-card-subtitle> | ||
| 6 | <mat-card-content *ngIf="entry.value.state === 'loading'"> | ||
| 7 | <mat-spinner style="margin: auto"></mat-spinner> | ||
| 8 | </mat-card-content> | ||
| 9 | <mat-card-actions> | ||
| 10 | <button mat-raised-button color="primary" (click)="claim(entry.key)" [disabled]="entry.value.state === 'loaded' || entry.value.state === 'claiming' ? null : true"> | ||
| 11 | <mat-icon aria-hidden="false" aria-label="Claim" svgIcon="check"></mat-icon> | ||
| 12 | <span *ngIf="entry.value.state !== 'claimed'">Claim</span> | ||
| 13 | <span *ngIf="entry.value.state === 'claimed'">Claimed</span> | ||
| 14 | </button> | ||
| 15 | <button mat-stroked-button color="warn" (click)="forget(entry.key)"> | ||
| 16 | <mat-icon aria-hidden="false" aria-label="Forget" svgIcon="delete_forever"></mat-icon> | ||
| 17 | <span *ngIf="entry.value.state !== 'claimed'">Forget</span> | ||
| 18 | <span *ngIf="entry.value.state === 'claimed'">Hide</span> | ||
| 19 | </button> | ||
| 20 | </mat-card-actions> | ||
| 21 | <mat-card-footer *ngIf="entry.value.state === 'loaded' && entry.value.percent_expiration"> | ||
| 22 | <mat-progress-bar mode="determinate" [value]="entry.value.percent_expiration"></mat-progress-bar> | ||
| 23 | </mat-card-footer> | ||
| 24 | <mat-card-footer *ngIf="entry.value.state === 'claiming'"> | ||
| 25 | <mat-progress-bar mode="indeterminate"></mat-progress-bar> | ||
| 26 | </mat-card-footer> | ||
| 27 | </mat-card> | ||
| 28 | </ng-template> | ||
| 29 | </div> | ||
| 2 | 30 | ||
| 3 | <mat-icon aria-hidden="false" aria-label="Thumbs up" svgIcon="thumb_up"></mat-icon> | 31 | <button id="add-button" mat-fab color="accent" (click)="add()"> |
| 32 | <mat-icon aria-hidden="false" aria-label="Add" svgIcon="add"></mat-icon> | ||
| 33 | </button> | ||
diff --git a/overlays/spm/frontend/src/app/spm/spm.component.sass b/overlays/spm/frontend/src/app/spm/spm.component.sass index e69de29b..bda4dd4a 100644 --- a/overlays/spm/frontend/src/app/spm/spm.component.sass +++ b/overlays/spm/frontend/src/app/spm/spm.component.sass | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | #add-button | ||
| 2 | position: fixed | ||
| 3 | bottom: 16px | ||
| 4 | right: 16px | ||
| 5 | |||
| 6 | #mail-panel-container | ||
| 7 | margin-bottom: 88px | ||
| 8 | |||
| 9 | .mat-card | ||
| 10 | overflow: hidden | ||
| 11 | |||
| 12 | .mat-card-footer | ||
| 13 | margin: -16px | ||
diff --git a/overlays/spm/frontend/src/app/spm/spm.component.ts b/overlays/spm/frontend/src/app/spm/spm.component.ts index 7d172052..f0655b55 100644 --- a/overlays/spm/frontend/src/app/spm/spm.component.ts +++ b/overlays/spm/frontend/src/app/spm/spm.component.ts | |||
| @@ -1,24 +1,193 @@ | |||
| 1 | import { Component, OnInit } from '@angular/core'; | 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; |
| 2 | import { MatIconRegistry, MatIconModule } from '@angular/material/icon'; | 2 | import { MatIconRegistry, MatIconModule } from '@angular/material/icon'; |
| 3 | import { DomSanitizer } from "@angular/platform-browser"; | 3 | import { DomSanitizer } from "@angular/platform-browser"; |
| 4 | import { BehaviorSubject, interval, Subscription, catchError, throwError } from 'rxjs'; | ||
| 5 | import { HttpClient, HttpHeaders, HttpErrorResponse, HttpResponse } from '@angular/common/http'; | ||
| 6 | |||
| 7 | import { v4 as uuidv4 } from 'uuid'; | ||
| 8 | import jwtDecode, { JwtPayload } from "jwt-decode"; | ||
| 9 | |||
| 10 | import { map, withLatestFrom } from 'rxjs/operators'; | ||
| 11 | |||
| 12 | export interface LoadingSpmMail { | ||
| 13 | state: "loading"; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface LoadedSpmMail { | ||
| 17 | state: "loaded" | "claiming"; | ||
| 18 | local: string; | ||
| 19 | domain: string; | ||
| 20 | jwt: string; | ||
| 21 | start?: Date; | ||
| 22 | expiration?: Date; | ||
| 23 | percent_expiration?: number; | ||
| 24 | } | ||
| 25 | |||
| 26 | export interface ExpiredSpmMail { | ||
| 27 | state: "expired"; | ||
| 28 | local: string; | ||
| 29 | domain: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | export interface ClaimedSpmMail { | ||
| 33 | state: "claimed"; | ||
| 34 | local: string; | ||
| 35 | domain: string; | ||
| 36 | } | ||
| 37 | |||
| 38 | export interface SpmJwtUnregisteredPayload { | ||
| 39 | local: string; | ||
| 40 | } | ||
| 41 | |||
| 42 | type SpmJwtPayload = JwtPayload & SpmJwtUnregisteredPayload; | ||
| 43 | |||
| 44 | export type SpmMail = LoadingSpmMail | LoadedSpmMail | ClaimedSpmMail | ExpiredSpmMail; | ||
| 45 | |||
| 46 | function percentExpiration(start: Date, exp: Date, now?: Date) { | ||
| 47 | if (!now) { | ||
| 48 | now = new Date(); | ||
| 49 | } | ||
| 50 | |||
| 51 | if (now < start) { | ||
| 52 | return 100; | ||
| 53 | } else if (exp < now) { | ||
| 54 | return 0; | ||
| 55 | } else { | ||
| 56 | const length = exp.getTime() - start.getTime(); | ||
| 57 | const diff = exp.getTime() - now.getTime(); | ||
| 58 | return 100 * (diff / length); | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | function updateSpmExpiration(value: SpmMail): SpmMail { | ||
| 63 | if (value.state === 'loaded') { | ||
| 64 | const percent_expiration = value.expiration && value.start ? percentExpiration(value.start, value.expiration) : undefined; | ||
| 65 | if (percent_expiration !== undefined && percent_expiration <= 0) { | ||
| 66 | return {state: "expired", local: value.local, domain: value.domain}; | ||
| 67 | } else { | ||
| 68 | return {...value, percent_expiration: percent_expiration} | ||
| 69 | } | ||
| 70 | } else { | ||
| 71 | return value; | ||
| 72 | } | ||
| 73 | } | ||
| 4 | 74 | ||
| 5 | @Component({ | 75 | @Component({ |
| 6 | selector: 'app-spm', | 76 | selector: 'app-spm', |
| 7 | templateUrl: './spm.component.html', | 77 | templateUrl: './spm.component.html', |
| 8 | styleUrls: ['./spm.component.sass'] | 78 | styleUrls: ['./spm.component.sass'] |
| 9 | }) | 79 | }) |
| 10 | export class SpmComponent implements OnInit { | 80 | export class SpmComponent implements OnInit, OnDestroy { |
| 81 | spmMails$: BehaviorSubject<Map<string, SpmMail>> = new BehaviorSubject(new Map()); | ||
| 82 | http: HttpClient; | ||
| 83 | |||
| 84 | intervalSubscription?: Subscription; | ||
| 11 | 85 | ||
| 12 | constructor(private matIconRegistry: MatIconRegistry, | 86 | constructor(private matIconRegistry: MatIconRegistry, |
| 13 | private domSanitizer: DomSanitizer | 87 | private domSanitizer: DomSanitizer, |
| 88 | http: HttpClient, | ||
| 14 | ) { | 89 | ) { |
| 15 | this.matIconRegistry.addSvgIcon( | 90 | this.matIconRegistry.addSvgIcon( |
| 16 | `thumb_up`, | 91 | `add`, |
| 17 | this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/thumb_up/baseline.svg`) | 92 | this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/add/baseline.svg`) |
| 93 | ); | ||
| 94 | this.matIconRegistry.addSvgIcon( | ||
| 95 | `check`, | ||
| 96 | this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/check/baseline.svg`) | ||
| 97 | ); | ||
| 98 | this.matIconRegistry.addSvgIcon( | ||
| 99 | `delete_forever`, | ||
| 100 | this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/delete_forever/baseline.svg`) | ||
| 18 | ); | 101 | ); |
| 102 | |||
| 103 | this.http = http; | ||
| 104 | } | ||
| 105 | |||
| 106 | add() { | ||
| 107 | const ident = uuidv4(); | ||
| 108 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
| 109 | curr.set(ident, { state: 'loading' }); | ||
| 110 | this.spmMails$.next(curr); | ||
| 111 | this.http.get('/spm/generate', { headers: new HttpHeaders({ 'Accept': 'text/plain' }), observe: 'body' as const, responseType: 'text' as const }).subscribe(payload => this.load(ident, payload)) | ||
| 112 | } | ||
| 113 | |||
| 114 | load(k: string, encoded: string) { | ||
| 115 | const payload = jwtDecode<SpmJwtPayload>(encoded); | ||
| 116 | if (typeof payload.aud === 'string') { | ||
| 117 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
| 118 | curr.set(k, { | ||
| 119 | state: 'loaded', | ||
| 120 | local: payload.local, | ||
| 121 | domain: payload.aud, | ||
| 122 | jwt: encoded, | ||
| 123 | expiration: payload.exp ? new Date(1000 * payload.exp) : undefined, | ||
| 124 | start: payload.nbf ? new Date(1000 * payload.nbf) : undefined, | ||
| 125 | percent_expiration: payload.exp && payload.nbf ? percentExpiration(new Date(1000 * payload.nbf), new Date(1000 * payload.exp)) : undefined | ||
| 126 | }); | ||
| 127 | this.spmMails$.next(curr); | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | handleClaimError(k: string, error: HttpErrorResponse) { | ||
| 132 | this.setState(k, 'loaded'); | ||
| 133 | |||
| 134 | return throwError(() => new Error('Claiming failed.')); | ||
| 135 | } | ||
| 136 | |||
| 137 | claim(k: string) { | ||
| 138 | const val = this.spmMails$.getValue().get(k); | ||
| 139 | if (!val || val.state !== 'loaded') | ||
| 140 | return; | ||
| 141 | |||
| 142 | this.setState(k, 'claiming'); | ||
| 143 | |||
| 144 | this.http.post('/spm/claim', val.jwt, { observe: 'response' as const, headers: new HttpHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }) }).pipe(catchError(error => this.handleClaimError(k, error))).subscribe(response => this.claimed(k, response)) | ||
| 19 | } | 145 | } |
| 20 | 146 | ||
| 21 | ngOnInit(): void { | 147 | setState(k: string, state: 'loaded' | 'claiming') { |
| 148 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
| 149 | const val = curr.get(k); | ||
| 150 | if (!val || (val.state !== 'loaded' && val.state !== 'claiming')) | ||
| 151 | return; | ||
| 152 | |||
| 153 | curr.set(k, {...val, state: state}); | ||
| 154 | this.spmMails$.next(curr); | ||
| 155 | } | ||
| 156 | |||
| 157 | claimed(k: string, response: HttpResponse<any>) { | ||
| 158 | if (response.status !== 204) { | ||
| 159 | this.setState(k, 'loaded'); | ||
| 160 | return; | ||
| 161 | } | ||
| 162 | |||
| 163 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
| 164 | const val = curr.get(k); | ||
| 165 | if (val && (val.state === 'claiming' || val.state === 'loaded')) { | ||
| 166 | curr.set(k, {state: "claimed", local: val.local, domain: val.domain}); | ||
| 167 | } | ||
| 168 | this.spmMails$.next(curr); | ||
| 22 | } | 169 | } |
| 23 | 170 | ||
| 171 | forget(k: string) { | ||
| 172 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
| 173 | curr.delete(k); | ||
| 174 | this.spmMails$.next(curr); | ||
| 175 | } | ||
| 176 | |||
| 177 | stepSpmMails(curr: Map<string, SpmMail>) { | ||
| 178 | curr.forEach((value, key) => curr.set(key, updateSpmExpiration(value))); | ||
| 179 | this.spmMails$.next(curr); | ||
| 180 | } | ||
| 181 | |||
| 182 | ngOnInit() { | ||
| 183 | this.intervalSubscription = interval(1000).pipe(withLatestFrom(this.spmMails$)).subscribe(([_, mails]: [number, Map<string, SpmMail>]) => this.stepSpmMails(mails)); | ||
| 184 | } | ||
| 185 | |||
| 186 | ngOnDestroy() { | ||
| 187 | this.intervalSubscription?.unsubscribe(); | ||
| 188 | } | ||
| 189 | |||
| 190 | asIsOrder(a: any, b: any) { | ||
| 191 | return -1; | ||
| 192 | } | ||
| 24 | } | 193 | } |
diff --git a/overlays/spm/frontend/src/app/spm/spm.module.ts b/overlays/spm/frontend/src/app/spm/spm.module.ts new file mode 100644 index 00000000..f51e6e98 --- /dev/null +++ b/overlays/spm/frontend/src/app/spm/spm.module.ts | |||
| @@ -0,0 +1,31 @@ | |||
| 1 | import { NgModule } from '@angular/core'; | ||
| 2 | import { CommonModule } from '@angular/common'; | ||
| 3 | import { SpmComponent } from './spm.component'; | ||
| 4 | import { RouterModule } from '@angular/router'; | ||
| 5 | |||
| 6 | import { MatIconModule } from '@angular/material/icon'; | ||
| 7 | import { MatButtonModule } from '@angular/material/button'; | ||
| 8 | import { MatCardModule } from '@angular/material/card'; | ||
| 9 | import { FlexLayoutModule } from "@angular/flex-layout"; | ||
| 10 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; | ||
| 11 | import { MatProgressBarModule } from '@angular/material/progress-bar'; | ||
| 12 | |||
| 13 | @NgModule({ | ||
| 14 | declarations: [SpmComponent], | ||
| 15 | exports: [SpmComponent], | ||
| 16 | imports: [ | ||
| 17 | RouterModule.forChild([{ | ||
| 18 | path: '', | ||
| 19 | pathMatch: 'full', | ||
| 20 | component: SpmComponent | ||
| 21 | }]), | ||
| 22 | MatIconModule, | ||
| 23 | MatButtonModule, | ||
| 24 | MatCardModule, | ||
| 25 | CommonModule, | ||
| 26 | FlexLayoutModule, | ||
| 27 | MatProgressSpinnerModule, | ||
| 28 | MatProgressBarModule, | ||
| 29 | ] | ||
| 30 | }) | ||
| 31 | export class SpmModule {} | ||
diff --git a/overlays/spm/frontend/src/custom-theme.scss b/overlays/spm/frontend/src/custom-theme.scss index 5c2d48aa..36369bd5 100644 --- a/overlays/spm/frontend/src/custom-theme.scss +++ b/overlays/spm/frontend/src/custom-theme.scss | |||
| @@ -49,3 +49,5 @@ $spm-frontend-dark-theme: mat.define-dark-theme(( | |||
| 49 | 49 | ||
| 50 | html, body { height: 100%; } | 50 | html, body { height: 100%; } |
| 51 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } | 51 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } |
| 52 | |||
| 53 | .mono { font-family: "Source Sans Pro", monospace; } | ||
diff --git a/overlays/spm/frontend/src/styles.sass b/overlays/spm/frontend/src/styles.sass index 4f2cb82a..4ffe0b36 100644 --- a/overlays/spm/frontend/src/styles.sass +++ b/overlays/spm/frontend/src/styles.sass | |||
| @@ -1,2 +1,3 @@ | |||
| 1 | /* You can add global styles to this file, and also import other style files */ | 1 | /* You can add global styles to this file, and also import other style files */ |
| 2 | @import @fontsource/roboto | 2 | @import @fontsource/roboto |
| 3 | @import @fontsource/source-sans-pro | ||
