From 3bf77d7c3144b16d55f35998dddc0d67bb8c17b2 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Tue, 7 Jun 2022 21:38:00 +0200 Subject: ... --- .../spm/frontend/src/app/app-routing.module.ts | 11 +- overlays/spm/frontend/src/app/app.component.html | 28 +++- overlays/spm/frontend/src/app/app.component.sass | 2 + overlays/spm/frontend/src/app/app.component.ts | 89 +++++++++- overlays/spm/frontend/src/app/app.module.ts | 14 +- .../spm/frontend/src/app/color-scheme.service.ts | 62 ------- .../app/page-not-found/page-not-found.module.ts | 16 ++ .../spm/frontend/src/app/spm/spm.component.html | 34 +++- .../spm/frontend/src/app/spm/spm.component.sass | 13 ++ overlays/spm/frontend/src/app/spm/spm.component.ts | 181 ++++++++++++++++++++- overlays/spm/frontend/src/app/spm/spm.module.ts | 31 ++++ overlays/spm/frontend/src/custom-theme.scss | 2 + overlays/spm/frontend/src/styles.sass | 1 + 13 files changed, 388 insertions(+), 96 deletions(-) delete mode 100644 overlays/spm/frontend/src/app/color-scheme.service.ts create mode 100644 overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts create mode 100644 overlays/spm/frontend/src/app/spm/spm.module.ts (limited to 'overlays/spm/frontend/src') 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 @@ import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; - -import { SpmComponent } from './spm/spm.component'; -import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; +import { RouterModule, Routes, PreloadAllModules } from '@angular/router'; const routes: Routes = [ - { path: 'spm', component: SpmComponent }, + { path: 'spm', loadChildren: () => import('./spm/spm.module').then(m => m.SpmModule) }, { path: '', redirectTo: '/spm', pathMatch: 'full' }, - { path: '**', component: PageNotFoundComponent } + { path: '**', loadChildren: () => import('./page-not-found/page-not-found.module').then(m => m.PageNotFoundModule) } ]; @NgModule({ - imports: [RouterModule.forRoot(routes)], + imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })], exports: [RouterModule] }) 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 @@ - -
- spm -
-
- - +
+ +
+ +
+
+ spm +
+
+ + +
+
+ +
+
+
+
+ +
+
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 @@ +.content + 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 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, Inject, Renderer2, RendererFactory2 } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { MediaMatcher } from '@angular/cdk/layout'; +import { LocalStorage } from 'ngx-webstorage'; +import { BehaviorSubject, Subscription, Subject } from 'rxjs'; +import { Router, RouterEvent, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { ColorSchemeService } from './color-scheme.service'; +import { map, filter } from 'rxjs/operators'; + +type ColorScheme = 'dark' | 'light'; + +export interface DomainResponse { + domain: string; +} @Component({ selector: 'app-root', @@ -8,14 +20,81 @@ import { ColorSchemeService } from './color-scheme.service'; styleUrls: ['./app.component.sass'] }) export class AppComponent implements OnInit, OnDestroy { + private renderer: Renderer2; + colorSchemeMatcher: MediaQueryList; + title = 'spm-frontend'; - constructor(private colorSchemeService: ColorSchemeService) {} + public activeColorScheme$: BehaviorSubject = new BehaviorSubject('dark'); + + @LocalStorage('prefers-color-scheme') + public colorSchemeOverride?: ColorScheme; + + colorSchemeSubscription?: Subscription; + + router: Router; + public routerLoading$: Subject = new Subject(); + routerEventSubscription?: Subscription; + + http: HttpClient; + apiDomain$: Subject = new Subject(); + + constructor( rendererFactory: RendererFactory2, + mediaMatcher: MediaMatcher, + @Inject(DOCUMENT) private document: Document, + router: Router, + http: HttpClient, + ) { + this.renderer = rendererFactory.createRenderer(null, null); + this.colorSchemeMatcher = mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); + this.document = document; + + this.colorSchemeListener = this.colorSchemeListener.bind(this); + this.colorSchemeMatcher.addEventListener('change', this.colorSchemeListener); + this.activeColorScheme$.next(this.getColorScheme()); + + this.router = router; + this.http = http; + } + + private colorSchemeListener(event: { matches: boolean; }) { + this.activeColorScheme$.next(this.getColorScheme(event)); + } + + private getColorScheme(event?: { matches: boolean; }): ColorScheme { + if (this.colorSchemeOverride) { + return this.colorSchemeOverride; + } else if (event) { + return event.matches ? 'dark' : 'light'; + } else { + return this.colorSchemeMatcher.matches ? 'dark' : 'light'; + } + } + + updateColorScheme(scheme: ColorScheme) { + this.colorSchemeOverride = scheme; + this.activeColorScheme$.next(scheme); + } ngOnInit() { - this.colorSchemeService.init(); + this.colorSchemeSubscription = this.activeColorScheme$.subscribe(scheme => { this.renderer.setAttribute(this.document.body, 'data-color-scheme', scheme); }); + + this.routerEventSubscription = this.router.events.pipe( + filter(event => + event instanceof NavigationStart || + event instanceof NavigationEnd || + event instanceof NavigationCancel || + event instanceof NavigationError + ), + map(event => event instanceof NavigationStart) + ).subscribe(this.routerLoading$); + + this.http.get('/domain', { headers: new HttpHeaders({ 'Accept': 'application/json' }) }).pipe(map((resp: DomainResponse) => resp.domain)).subscribe(this.apiDomain$); } ngOnDestroy() { - this.colorSchemeService.destroy(); + this.colorSchemeMatcher.removeEventListener('change', this.colorSchemeListener); + + this.colorSchemeSubscription?.unsubscribe(); + this.routerEventSubscription?.unsubscribe(); } } 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 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; +import { NgxWebstorageModule } from 'ngx-webstorage'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; -import { SpmComponent } from './spm/spm.component'; -import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { MatIconModule } from '@angular/material/icon'; import { HttpClientModule } from "@angular/common/http"; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FlexLayoutModule } from "@angular/flex-layout"; @NgModule({ declarations: [ AppComponent, - SpmComponent, - PageNotFoundComponent ], imports: [ BrowserModule, + NgxWebstorageModule.forRoot(), AppRoutingModule, BrowserAnimationsModule, - MatIconModule, HttpClientModule, MatToolbarModule, - MatButtonModule + MatButtonModule, + MatProgressSpinnerModule, + FlexLayoutModule, ], providers: [], 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 @@ -import { Injectable, Renderer2, RendererFactory2 } from '@angular/core'; -import { MediaMatcher } from '@angular/cdk/layout'; -import { LocalStorage } from 'ngx-webstorage'; - -type ColorScheme = "dark" | "light"; - -@Injectable({ - providedIn: 'root' -}) -export class ColorSchemeService { - private renderer: Renderer2; - matcher!: MediaQueryList; - - @LocalStorage('prefers-color-scheme') - public colorSchemeOverride: ColorScheme; - - public activeColorScheme: BehaviorSubject = new BehaviorSubject("light"); - - constructor(rendererFactory: RendererFactory2, - mediaMatcher: MediaMatcher - ) { - // Create new renderer from renderFactory, to make it possible to use renderer2 in a service - this.renderer = rendererFactory.createRenderer(null, null); - this.matcher = mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); - - this._listener = this._listener.bind(this); - } - - init() { - this.matcher.addEventListener('change', this._listener); - this.load(); - } - - _listener(event: { matches: any; }) { - this.activeColorScheme.next(event.matches ? 'dark' : 'light'); - } - - destroy() { - this.matcher.removeEventListener('change', this._listener); - } - - _getColorScheme() { - if (this.colorSchemeOverride) { - this.colorSchemeOverride - } else { - this.matcher.matches ? 'dark' : 'light' - } - } - - load() { - this.activeColorScheme.next(this._getColorScheme()); - } - - update(scheme: ColorScheme) { - this.colorSchemeOverride = scheme; - this.activeColorScheme.next(scheme); - } - - currentActive() { - return this.colorScheme; - } -} 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 @@ +import { NgModule } from '@angular/core'; +import { PageNotFoundComponent } from './page-not-found.component'; +import { RouterModule } from '@angular/router'; + +@NgModule({ + declarations: [PageNotFoundComponent], + exports: [PageNotFoundComponent], + imports: [ + RouterModule.forChild([{ + path: '', + pathMatch: 'full', + component: PageNotFoundComponent + }]), + ] +}) +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 @@ -

spm works!

+
+ + + {{entry.value.local}} + @{{entry.value.domain}} + + + + + + + + + + + + + + + +
- + 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 @@ +#add-button + position: fixed + bottom: 16px + right: 16px + +#mail-panel-container + margin-bottom: 88px + +.mat-card + overflow: hidden + +.mat-card-footer + 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 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, OnDestroy } from '@angular/core'; import { MatIconRegistry, MatIconModule } from '@angular/material/icon'; import { DomSanitizer } from "@angular/platform-browser"; +import { BehaviorSubject, interval, Subscription, catchError, throwError } from 'rxjs'; +import { HttpClient, HttpHeaders, HttpErrorResponse, HttpResponse } from '@angular/common/http'; + +import { v4 as uuidv4 } from 'uuid'; +import jwtDecode, { JwtPayload } from "jwt-decode"; + +import { map, withLatestFrom } from 'rxjs/operators'; + +export interface LoadingSpmMail { + state: "loading"; +} + +export interface LoadedSpmMail { + state: "loaded" | "claiming"; + local: string; + domain: string; + jwt: string; + start?: Date; + expiration?: Date; + percent_expiration?: number; +} + +export interface ExpiredSpmMail { + state: "expired"; + local: string; + domain: string; +} + +export interface ClaimedSpmMail { + state: "claimed"; + local: string; + domain: string; +} + +export interface SpmJwtUnregisteredPayload { + local: string; +} + +type SpmJwtPayload = JwtPayload & SpmJwtUnregisteredPayload; + +export type SpmMail = LoadingSpmMail | LoadedSpmMail | ClaimedSpmMail | ExpiredSpmMail; + +function percentExpiration(start: Date, exp: Date, now?: Date) { + if (!now) { + now = new Date(); + } + + if (now < start) { + return 100; + } else if (exp < now) { + return 0; + } else { + const length = exp.getTime() - start.getTime(); + const diff = exp.getTime() - now.getTime(); + return 100 * (diff / length); + } +} + +function updateSpmExpiration(value: SpmMail): SpmMail { + if (value.state === 'loaded') { + const percent_expiration = value.expiration && value.start ? percentExpiration(value.start, value.expiration) : undefined; + if (percent_expiration !== undefined && percent_expiration <= 0) { + return {state: "expired", local: value.local, domain: value.domain}; + } else { + return {...value, percent_expiration: percent_expiration} + } + } else { + return value; + } +} @Component({ selector: 'app-spm', templateUrl: './spm.component.html', styleUrls: ['./spm.component.sass'] }) -export class SpmComponent implements OnInit { +export class SpmComponent implements OnInit, OnDestroy { + spmMails$: BehaviorSubject> = new BehaviorSubject(new Map()); + http: HttpClient; + + intervalSubscription?: Subscription; constructor(private matIconRegistry: MatIconRegistry, - private domSanitizer: DomSanitizer + private domSanitizer: DomSanitizer, + http: HttpClient, ) { this.matIconRegistry.addSvgIcon( - `thumb_up`, - this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/thumb_up/baseline.svg`) + `add`, + this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/add/baseline.svg`) + ); + this.matIconRegistry.addSvgIcon( + `check`, + this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/check/baseline.svg`) + ); + this.matIconRegistry.addSvgIcon( + `delete_forever`, + this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/delete_forever/baseline.svg`) ); + + this.http = http; + } + + add() { + const ident = uuidv4(); + const curr: Map = this.spmMails$.getValue(); + curr.set(ident, { state: 'loading' }); + this.spmMails$.next(curr); + 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)) + } + + load(k: string, encoded: string) { + const payload = jwtDecode(encoded); + if (typeof payload.aud === 'string') { + const curr: Map = this.spmMails$.getValue(); + curr.set(k, { + state: 'loaded', + local: payload.local, + domain: payload.aud, + jwt: encoded, + expiration: payload.exp ? new Date(1000 * payload.exp) : undefined, + start: payload.nbf ? new Date(1000 * payload.nbf) : undefined, + percent_expiration: payload.exp && payload.nbf ? percentExpiration(new Date(1000 * payload.nbf), new Date(1000 * payload.exp)) : undefined + }); + this.spmMails$.next(curr); + } + } + + handleClaimError(k: string, error: HttpErrorResponse) { + this.setState(k, 'loaded'); + + return throwError(() => new Error('Claiming failed.')); + } + + claim(k: string) { + const val = this.spmMails$.getValue().get(k); + if (!val || val.state !== 'loaded') + return; + + this.setState(k, 'claiming'); + + 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)) } - ngOnInit(): void { + setState(k: string, state: 'loaded' | 'claiming') { + const curr: Map = this.spmMails$.getValue(); + const val = curr.get(k); + if (!val || (val.state !== 'loaded' && val.state !== 'claiming')) + return; + + curr.set(k, {...val, state: state}); + this.spmMails$.next(curr); + } + + claimed(k: string, response: HttpResponse) { + if (response.status !== 204) { + this.setState(k, 'loaded'); + return; + } + + const curr: Map = this.spmMails$.getValue(); + const val = curr.get(k); + if (val && (val.state === 'claiming' || val.state === 'loaded')) { + curr.set(k, {state: "claimed", local: val.local, domain: val.domain}); + } + this.spmMails$.next(curr); } + forget(k: string) { + const curr: Map = this.spmMails$.getValue(); + curr.delete(k); + this.spmMails$.next(curr); + } + + stepSpmMails(curr: Map) { + curr.forEach((value, key) => curr.set(key, updateSpmExpiration(value))); + this.spmMails$.next(curr); + } + + ngOnInit() { + this.intervalSubscription = interval(1000).pipe(withLatestFrom(this.spmMails$)).subscribe(([_, mails]: [number, Map]) => this.stepSpmMails(mails)); + } + + ngOnDestroy() { + this.intervalSubscription?.unsubscribe(); + } + + asIsOrder(a: any, b: any) { + return -1; + } } 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 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SpmComponent } from './spm.component'; +import { RouterModule } from '@angular/router'; + +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { FlexLayoutModule } from "@angular/flex-layout"; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +@NgModule({ + declarations: [SpmComponent], + exports: [SpmComponent], + imports: [ + RouterModule.forChild([{ + path: '', + pathMatch: 'full', + component: SpmComponent + }]), + MatIconModule, + MatButtonModule, + MatCardModule, + CommonModule, + FlexLayoutModule, + MatProgressSpinnerModule, + MatProgressBarModule, + ] +}) +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(( html, body { height: 100%; } body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } + +.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 @@ /* You can add global styles to this file, and also import other style files */ @import @fontsource/roboto +@import @fontsource/source-sans-pro -- cgit v1.2.3