From ae42ebee855833e6735fd54c62619b772b1596a9 Mon Sep 17 00:00:00 2001 From: Daniel von Atzigen Date: Thu, 16 May 2024 12:10:55 +0200 Subject: [PATCH] Add alerts Replace error page navigation with alerts Move alert feature into `client-shared` library Remove duplicate material styles Fix http response errors being displayed as empty text Fix alert icons getting smaller on long error messages Fix lint errors --- apps/client-asset-sg/.eslintrc.json | 16 --- .../src/app/app.component.html | 9 +- .../src/app/app.component.scss | 14 +++ apps/client-asset-sg/src/app/app.module.ts | 18 +-- .../app/components/error/error.component.html | 6 - .../app/components/error/error.component.scss | 13 -- .../app/components/error/error.component.ts | 24 ---- apps/client-asset-sg/src/mat-styles.scss | 26 +--- apps/client-asset-sg/src/styles.scss | 1 + apps/client-asset-sg/src/theme.scss | 21 ++++ .../auth/src/lib/services/auth.interceptor.ts | 27 +++- libs/client-shared/.eslintrc.json | 16 --- .../alert-list/alert-list.component.html | 7 ++ .../alert-list/alert-list.component.scss | 9 ++ .../alert/alert-list/alert-list.component.ts | 18 +++ .../src/lib/features/alert/alert.actions.ts | 10 ++ .../src/lib/features/alert/alert.model.ts | 56 +++++++++ .../src/lib/features/alert/alert.module.ts | 25 ++++ .../src/lib/features/alert/alert.reducer.ts | 39 ++++++ .../src/lib/features/alert/alert.selectors.ts | 10 ++ .../features/alert/alert/alert.component.html | 5 + .../features/alert/alert/alert.component.scss | 118 ++++++++++++++++++ .../features/alert/alert/alert.component.ts | 95 ++++++++++++++ libs/client-shared/src/lib/icons/checkmark.ts | 10 ++ libs/client-shared/src/lib/icons/failure.ts | 6 + libs/client-shared/src/lib/icons/index.ts | 6 + libs/client-shared/src/lib/icons/warn.ts | 8 ++ tsconfig.base.json | 1 + 28 files changed, 496 insertions(+), 118 deletions(-) delete mode 100644 apps/client-asset-sg/src/app/components/error/error.component.html delete mode 100644 apps/client-asset-sg/src/app/components/error/error.component.scss delete mode 100644 apps/client-asset-sg/src/app/components/error/error.component.ts create mode 100644 apps/client-asset-sg/src/theme.scss create mode 100644 libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.html create mode 100644 libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.scss create mode 100644 libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.ts create mode 100644 libs/client-shared/src/lib/features/alert/alert.actions.ts create mode 100644 libs/client-shared/src/lib/features/alert/alert.model.ts create mode 100644 libs/client-shared/src/lib/features/alert/alert.module.ts create mode 100644 libs/client-shared/src/lib/features/alert/alert.reducer.ts create mode 100644 libs/client-shared/src/lib/features/alert/alert.selectors.ts create mode 100644 libs/client-shared/src/lib/features/alert/alert/alert.component.html create mode 100644 libs/client-shared/src/lib/features/alert/alert/alert.component.scss create mode 100644 libs/client-shared/src/lib/features/alert/alert/alert.component.ts create mode 100644 libs/client-shared/src/lib/icons/checkmark.ts create mode 100644 libs/client-shared/src/lib/icons/failure.ts create mode 100644 libs/client-shared/src/lib/icons/warn.ts diff --git a/apps/client-asset-sg/.eslintrc.json b/apps/client-asset-sg/.eslintrc.json index 4fc39f3e..a0a3b2fc 100644 --- a/apps/client-asset-sg/.eslintrc.json +++ b/apps/client-asset-sg/.eslintrc.json @@ -5,22 +5,6 @@ { "files": ["*.ts"], "rules": { - "@angular-eslint/directive-selector": [ - "error", - { - "type": "attribute", - "prefix": "assetSg", - "style": "camelCase" - } - ], - "@angular-eslint/component-selector": [ - "error", - { - "type": "element", - "prefix": "asset-sg", - "style": "kebab-case" - } - ], "no-restricted-imports": [ "error", { diff --git a/apps/client-asset-sg/src/app/app.component.html b/apps/client-asset-sg/src/app/app.component.html index 52c102ab..31020944 100644 --- a/apps/client-asset-sg/src/app/app.component.html +++ b/apps/client-asset-sg/src/app/app.component.html @@ -1,10 +1,13 @@ - + - +
-
+
+
+
    +
    diff --git a/apps/client-asset-sg/src/app/app.component.scss b/apps/client-asset-sg/src/app/app.component.scss index 889fa419..0da765b2 100644 --- a/apps/client-asset-sg/src/app/app.component.scss +++ b/apps/client-asset-sg/src/app/app.component.scss @@ -43,3 +43,17 @@ asset-sg-menu-bar { --viewport-width: min(calc(100vw - #{$menu-bar-width}), calc(1920px - #{$menu-bar-width})); } } + +.alerts { + position: fixed; + width: min(30rem, 95vw); + + bottom: 2rem; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + + /// angular-cdk-overlay is at 1000, so we go to 1500. + z-index: 1500; +} diff --git a/apps/client-asset-sg/src/app/app.module.ts b/apps/client-asset-sg/src/app/app.module.ts index abfd15f4..bd7c2534 100644 --- a/apps/client-asset-sg/src/app/app.module.ts +++ b/apps/client-asset-sg/src/app/app.module.ts @@ -16,8 +16,6 @@ import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-transla import { ForModule } from '@rx-angular/template/for'; import { LetModule } from '@rx-angular/template/let'; import { PushModule } from '@rx-angular/template/push'; -import * as O from 'fp-ts/Option'; -import * as C from 'io-ts/Codec'; import { AuthInterceptor, AuthModule } from '@asset-sg/auth'; import { @@ -28,6 +26,7 @@ import { currentLangFactory, icons, } from '@asset-sg/client-shared'; +import { AlertModule } from '@asset-sg/client-shared/lib/features/alert/alert.module'; import { storeLogger } from '@asset-sg/core'; import { environment } from '../environments/environment'; @@ -36,7 +35,6 @@ import { adminGuard, editorGuard } from './app-guards'; import { assetsPageMatcher } from './app-matchers'; import { AppComponent } from './app.component'; import { AppBarComponent, MenuBarComponent, NotFoundComponent, RedirectToLangComponent } from './components'; -import { ErrorComponent } from './components/error/error.component'; import { appTranslations } from './i18n'; import { AppSharedStateEffects } from './state'; import { appSharedStateReducer } from './state/app-shared.reducer'; @@ -50,7 +48,6 @@ registerLocaleData(locale_deCH, 'de-CH'); NotFoundComponent, AppBarComponent, MenuBarComponent, - ErrorComponent, ], imports: [ BrowserModule, @@ -75,10 +72,6 @@ registerLocaleData(locale_deCH, 'de-CH'); loadChildren: () => import('@asset-sg/asset-editor').then(m => m.AssetEditorModule), canActivate: [editorGuard], }, - { - path: ':lang/error', - component: ErrorComponent, - }, { matcher: assetsPageMatcher, loadChildren: () => import('@asset-sg/asset-viewer').then(m => m.AssetViewerModule), @@ -117,6 +110,7 @@ registerLocaleData(locale_deCH, 'de-CH'); DialogModule, A11yModule, AuthModule, + AlertModule, ], providers: [ provideSvgIcons(icons), @@ -137,11 +131,3 @@ export class AppModule { export interface Encoder { readonly encode: (a: A) => O; } - -function optionFromNullable(encoder: Encoder): Encoder> { - return { - encode: O.fold(() => null, encoder.encode), - }; -} - -const foooobar = optionFromNullable(C.string); diff --git a/apps/client-asset-sg/src/app/components/error/error.component.html b/apps/client-asset-sg/src/app/components/error/error.component.html deleted file mode 100644 index 954e8fd2..00000000 --- a/apps/client-asset-sg/src/app/components/error/error.component.html +++ /dev/null @@ -1,6 +0,0 @@ - -
    - {{errorMessage}} - -
    - diff --git a/apps/client-asset-sg/src/app/components/error/error.component.scss b/apps/client-asset-sg/src/app/components/error/error.component.scss deleted file mode 100644 index d3503d87..00000000 --- a/apps/client-asset-sg/src/app/components/error/error.component.scss +++ /dev/null @@ -1,13 +0,0 @@ -.error-page { - display: flex; - flex-direction: column; - gap: 20px; - position: absolute; - padding: 40px; - width: 100%; - background-color: red; - color: white; - align-items: center; - font-size: 24px; - font-weight: bolder; -} diff --git a/apps/client-asset-sg/src/app/components/error/error.component.ts b/apps/client-asset-sg/src/app/components/error/error.component.ts deleted file mode 100644 index cc161e7a..00000000 --- a/apps/client-asset-sg/src/app/components/error/error.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { AuthService } from '@asset-sg/auth'; - -@Component({ - selector: 'asset-sg-error', - templateUrl: './error.component.html', - styleUrls: ['./error.component.scss'], -}) -export class ErrorComponent { - errorMessage: string; - private readonly _authService = inject(AuthService); - - constructor(private route: ActivatedRoute, private router: Router) { - const navigation = this.router.getCurrentNavigation(); - console.log(navigation?.extras.state?.['errorMessage']); - this.errorMessage = navigation?.extras.state?.['errorMessage'] ?? 'An error occurred, please try again later.'; - } - - public logout() { - this._authService.logOut(); - } -} diff --git a/apps/client-asset-sg/src/mat-styles.scss b/apps/client-asset-sg/src/mat-styles.scss index 6aaafd41..4b1f38ab 100644 --- a/apps/client-asset-sg/src/mat-styles.scss +++ b/apps/client-asset-sg/src/mat-styles.scss @@ -1,27 +1,13 @@ @use 'app/styles/variables'; @use 'app/styles/mixins' as mixins; @use '@angular/material' as mat; -@use 'sass:map'; -@include mat.core(); - -$asset-sg-palette: map.set(mat.$indigo-palette, 500, variables.$cyan-03); +@import 'theme'; -$asset-sg-primary: mat.define-palette($asset-sg-palette); -$asset-sg-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400); -$asset-sg-warn: mat.define-palette(mat.$red-palette); +@include mat.core(); -$asset-sg-theme: mat.define-light-theme( - ( - color: ( - primary: $asset-sg-primary, - accent: $asset-sg-accent, - warn: $asset-sg-warn, - ), - ) -); +@include mat.all-component-themes($asset-sg-theme); -@include mat.select-theme($asset-sg-theme); .mat-mdc-select-value { color: variables.$grey-09; } @@ -112,7 +98,6 @@ mat-option.mat-mdc-option { } } -@include mat.legacy-autocomplete-theme($asset-sg-theme); .mat-autocomplete-panel .mat-option.mat-selected:not(.mat-active):not(:hover):not(.mat-option-disabled) { color: inherit; background-color: variables.$grey-01; @@ -137,7 +122,6 @@ mat-option.mat-option { } } -@include mat.form-field-theme($asset-sg-theme); .mat-mdc-form-field { &:hover, &.mat-focused { @@ -230,7 +214,6 @@ mat-option.mat-option { } } -@include mat.datepicker-theme($asset-sg-theme); button.mat-icon-button { border-radius: 0; background-color: white; @@ -266,8 +249,6 @@ mat-datepicker-content.mat-datepicker-content .mat-calendar { height: 24.5rem; } -@include mat.progress-bar-theme($asset-sg-theme); -@include mat.radio-theme($asset-sg-theme); .mat-mdc-radio-button.mat-accent { --mdc-radio-selected-focus-icon-color: #{variables.$dark-red}; --mdc-radio-selected-hover-icon-color: #{variables.$dark-red}; @@ -276,7 +257,6 @@ mat-datepicker-content.mat-datepicker-content .mat-calendar { --mat-mdc-radio-checked-ripple-color: #{variables.$dark-red}; } -@include mat.menu-theme($asset-sg-theme); .mat-mdc-menu-item:hover:not([disabled]), .mat-mdc-menu-item.cdk-program-focused:not([disabled]), .mat-mdc-menu-item.cdk-keyboard-focused:not([disabled]), diff --git a/apps/client-asset-sg/src/styles.scss b/apps/client-asset-sg/src/styles.scss index 0aeb8664..738d4233 100644 --- a/apps/client-asset-sg/src/styles.scss +++ b/apps/client-asset-sg/src/styles.scss @@ -3,6 +3,7 @@ @use 'app/styles/variables'; @use './open-layers'; + @font-face { font-family: 'Inter'; font-style: normal; diff --git a/apps/client-asset-sg/src/theme.scss b/apps/client-asset-sg/src/theme.scss new file mode 100644 index 00000000..d8c7d44d --- /dev/null +++ b/apps/client-asset-sg/src/theme.scss @@ -0,0 +1,21 @@ +@use 'app/styles/variables'; +@use 'app/styles/mixins' as mixins; +@use '@angular/material' as mat; +@use 'sass:map'; + +$asset-sg-palette: map.set(mat.$indigo-palette, 500, variables.$cyan-03); + +$asset-sg-primary: mat.define-palette($asset-sg-palette); +$asset-sg-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400); +$asset-sg-warn: mat.define-palette(mat.$red-palette); + +$asset-sg-theme: mat.define-light-theme( + ( + color: ( + primary: $asset-sg-primary, + accent: $asset-sg-accent, + warn: $asset-sg-warn, + ), + ) +); + diff --git a/libs/auth/src/lib/services/auth.interceptor.ts b/libs/auth/src/lib/services/auth.interceptor.ts index 384f3488..bb85e13a 100644 --- a/libs/auth/src/lib/services/auth.interceptor.ts +++ b/libs/auth/src/lib/services/auth.interceptor.ts @@ -1,13 +1,19 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; import { OAuthService } from 'angular-oauth2-oidc'; +import { ResponseError } from 'ol/net'; import { EMPTY, Observable, catchError } from 'rxjs'; +import { showAlert } from '@asset-sg/client-shared/lib/features/alert/alert.actions'; +import { AlertType } from '@asset-sg/client-shared/lib/features/alert/alert.model'; + @Injectable() export class AuthInterceptor implements HttpInterceptor { private _oauthService = inject(OAuthService); private _router: Router = inject(Router); + private readonly store = inject(Store); intercept(req: HttpRequest, next: HttpHandler): Observable> { const token = sessionStorage.getItem('access_token'); @@ -30,7 +36,26 @@ export class AuthInterceptor implements HttpInterceptor { } else { return next.handle(req).pipe( catchError((error: HttpErrorResponse) => { - this._router.navigate(['de/error'], { state: { errorMessage: error.error.error } }); + if (error.status === 401 || error.status === 403) { + this.store.dispatch(showAlert({ + alert: { + id: `auth-error-${error.status}`, + text: error.error.error, + type: AlertType.Error, + isPersistent: true, + }, + })); + } else { + console.log(error); + this.store.dispatch(showAlert({ + alert: { + id: `request-error-${error.status}-${error.url}`, + text: error.error.message, + type: AlertType.Error, + isPersistent: true, + }, + })); + } return EMPTY; }), ); diff --git a/libs/client-shared/.eslintrc.json b/libs/client-shared/.eslintrc.json index 95b31091..66cded6b 100644 --- a/libs/client-shared/.eslintrc.json +++ b/libs/client-shared/.eslintrc.json @@ -5,22 +5,6 @@ { "files": ["*.ts"], "rules": { - "@angular-eslint/directive-selector": [ - "error", - { - "type": "attribute", - "prefix": "assetSg", - "style": "camelCase" - } - ], - "@angular-eslint/component-selector": [ - "error", - { - "type": "element", - "prefix": "asset-sg", - "style": "kebab-case" - } - ], "@angular-eslint/no-host-metadata-property": "off", "@angular-eslint/no-output-rename": "off", "@typescript-eslint/member-ordering": "off" diff --git a/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.html b/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.html new file mode 100644 index 00000000..a366a24b --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.html @@ -0,0 +1,7 @@ + +
  • +
    diff --git a/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.scss b/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.scss new file mode 100644 index 00000000..77e94f47 --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.scss @@ -0,0 +1,9 @@ +:host { + display: flex; + flex-direction: column; + gap: 0.5rem; + + list-style: none; + margin: 0; + padding: 0; +} diff --git a/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.ts b/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.ts new file mode 100644 index 00000000..e5131635 --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert-list/alert-list.component.ts @@ -0,0 +1,18 @@ +import { Component, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { AlertId } from '../alert.model'; +import { AlertEntry, AlertState } from '../alert.reducer'; +import { selectAlerts } from '../alert.selectors'; + +@Component({ + selector: 'ul[app-alert-list]', + templateUrl: './alert-list.component.html', + styleUrls: ['./alert-list.component.scss'], +}) +export class AlertListComponent { + private readonly store = inject(Store); + readonly alerts$ = this.store.select(selectAlerts); + + getAlertId = (_index: number, entry: AlertEntry): AlertId => entry.alert.id; +} diff --git a/libs/client-shared/src/lib/features/alert/alert.actions.ts b/libs/client-shared/src/lib/features/alert/alert.actions.ts new file mode 100644 index 00000000..47cb92ce --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert.actions.ts @@ -0,0 +1,10 @@ +import { createAction, props } from '@ngrx/store'; + +import { Alert, AlertId } from './alert.model'; + +export const showAlert = createAction('[Alert] Create Alert', props<{ alert: Alert }>()); +export const hideAlert = createAction('[Alert] Remove Alert', props<{ id: AlertId }>()); + +export type AlertAction = + | typeof showAlert + | typeof hideAlert diff --git a/libs/client-shared/src/lib/features/alert/alert.model.ts b/libs/client-shared/src/lib/features/alert/alert.model.ts new file mode 100644 index 00000000..a5b35ec2 --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert.model.ts @@ -0,0 +1,56 @@ + +export type AlertId = string; + +/** + * A message that is displayed to the user. + * It is usually a feedback to an event of some kind. + */ +export interface Alert { + /** + * A unique id for this alert. + * This ensures that a message is only displayed once, + * even when the events that are triggering it overlap. + */ + id: AlertId + + /** + * The alert's type, influencing its appearance. + */ + type: AlertType + + /** + * The display text. + */ + text: string + + /** + * Whether the alert stays until manually closed by the user, + * or automatically disappears after a fixed amount of time. + */ + isPersistent?: boolean, +} + +/** + * The visual styles that a {@link Alert} can have. + */ +export enum AlertType { + /** + * An expected and/or successful result. + */ + Success = 'success', + + /** + * Something that is informative, but not the direct or expected result of what the user is currently doing. + */ + Notice = 'notice', + + /** + * Something that requires further attention, but is not a full failure. + */ + Warning = 'warning', + + /** + * An unexpected and/or failed result. + */ + Error = 'error', +} diff --git a/libs/client-shared/src/lib/features/alert/alert.module.ts b/libs/client-shared/src/lib/features/alert/alert.module.ts new file mode 100644 index 00000000..714190b7 --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert.module.ts @@ -0,0 +1,25 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SvgIconComponent } from '@ngneat/svg-icon'; +import { StoreModule } from '@ngrx/store'; + +import { AlertComponent } from './alert/alert.component'; +import { AlertListComponent } from './alert-list/alert-list.component'; +import { alertFeature, alertReducer } from './alert.reducer'; + + +@NgModule({ + declarations: [ + AlertListComponent, + AlertComponent, + ], + imports: [ + CommonModule, + StoreModule.forFeature(alertFeature, alertReducer), + SvgIconComponent, + ], + exports: [ + AlertListComponent, + ], +}) +export class AlertModule {} diff --git a/libs/client-shared/src/lib/features/alert/alert.reducer.ts b/libs/client-shared/src/lib/features/alert/alert.reducer.ts new file mode 100644 index 00000000..de3f323b --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert.reducer.ts @@ -0,0 +1,39 @@ +import { createReducer, on } from '@ngrx/store'; + +import { hideAlert, showAlert } from './alert.actions'; +import { Alert, AlertId } from './alert.model'; + +export const alertFeature = 'alert'; + +export type AlertState = Record; + +export interface AlertEntry { + alert: Alert, + metadata: AlertMetadata, +} + +export interface AlertMetadata { + createdAt: Date +} + +const initialState: AlertState = {}; + +export const alertReducer = createReducer( + initialState, + on(showAlert, (state, { alert }) => { + const entry = { alert, metadata: { createdAt: new Date(), lifetime: 1 } }; + if (alert.id in state) { + // If there is already an alert with the same id, we overwrite it by direct assignment. + // With this, we preserve the id's insertion order, which ensures that the order of alerts does not change. + const newState = { ...state }; + newState[alert.id] = entry; + return newState; + } + return { ...state, [alert.id]: entry } + }), + on(hideAlert, (state, { id }) => { + const newState = { ...state }; + delete newState[id]; + return newState; + }), +); diff --git a/libs/client-shared/src/lib/features/alert/alert.selectors.ts b/libs/client-shared/src/lib/features/alert/alert.selectors.ts new file mode 100644 index 00000000..f32e0089 --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert.selectors.ts @@ -0,0 +1,10 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import { AlertState, alertFeature } from './alert.reducer'; + +export const selectAlertState = createFeatureSelector(alertFeature) + +export const selectAlerts = createSelector( + selectAlertState, + (state) => Object.values(state), +) diff --git a/libs/client-shared/src/lib/features/alert/alert/alert.component.html b/libs/client-shared/src/lib/features/alert/alert/alert.component.html new file mode 100644 index 00000000..8b9fbeb6 --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert/alert.component.html @@ -0,0 +1,5 @@ + + +

    {{alert.text}}

    + + diff --git a/libs/client-shared/src/lib/features/alert/alert/alert.component.scss b/libs/client-shared/src/lib/features/alert/alert/alert.component.scss new file mode 100644 index 00000000..41c2f689 --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert/alert.component.scss @@ -0,0 +1,118 @@ +@use '@angular/material' as mat; +@use '../../../../../../../apps/client-asset-sg/src/theme' as *; + +// This value might need to be changed depending on other style variables. +// It is necessary to use a fixed value here to allow all animations to work correctly. +$height: 57px; + +:host { + display: flex; + position: relative; + flex-direction: row; + width: 100%; + padding: 1rem; + align-items: center; + gap: 0.75rem; + overflow: hidden; + cursor: pointer; + + animation: 300ms ease-in slide-in; + transform-origin: bottom; +} + +:host.is-removed { + animation: 250ms ease-out slide-out; + max-height: 0; + padding-block: 0; +} + +p { + margin: 0; + flex: 1 1 auto; + word-wrap: normal; +} + +progress { + position: absolute; + top: 0; + left: 0; + + border: 0; + height: 0.5rem; + width: 100%; +} + +svg-icon { + display: flex; + justify-content: center; + align-items: center; + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; +} + +svg-icon.close { + transition: ease-in-out 250ms transform; +} + +:host:hover > svg-icon.close { + transform: rotate(90deg); +} + +@mixin colored($palette, $main-weight, $light-weight) { + color: mat.get-contrast-color-from-palette($palette, $main-weight); + background-color: mat.get-color-from-palette($palette, $main-weight); + + progress { + background-color: mat.get-color-from-palette($palette, $light-weight); + } + + progress::-webkit-progress-bar, progress::-moz-progress-bar { + background-color: mat.get-color-from-palette($palette, $main-weight); + } +} + +:host.is-success { + @include colored($asset-sg-primary, 600, 400); +} + +:host.is-notice { + @include colored($asset-sg-accent, 500, 300); +} + +:host.is-warning { + @include colored($asset-sg-warn, 200, 100); +} + +:host.is-error { + @include colored($asset-sg-warn, 500, 300); +} + +@keyframes slide-in { + from { + max-height: 0; + transform: scaleY(0); + opacity: 0; + } + to { + max-height: $height; + transform: scaleY(100%); + opacity: 1; + } +} + +@keyframes slide-out { + from { + max-height: $height; + padding-block: 0; + transform: scaleY(100%); + opacity: 1; + } + to { + max-height: 0; + padding-block: 0; + transform: scaleY(0); + opacity: 0; + } +} diff --git a/libs/client-shared/src/lib/features/alert/alert/alert.component.ts b/libs/client-shared/src/lib/features/alert/alert/alert.component.ts new file mode 100644 index 00000000..60d68004 --- /dev/null +++ b/libs/client-shared/src/lib/features/alert/alert/alert.component.ts @@ -0,0 +1,95 @@ +import { Component, HostBinding, HostListener, Input, OnDestroy, OnInit, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Subscription, asyncScheduler, interval } from 'rxjs'; + +import { hideAlert } from '../alert.actions'; +import { Alert, AlertType } from '../alert.model'; +import { AlertMetadata, AlertState } from '../alert.reducer'; + +@Component({ + selector: 'li[app-alert]', + templateUrl: './alert.component.html', + styleUrls: ['./alert.component.scss'], +}) +export class AlertComponent implements OnInit, OnDestroy { + @Input() + alert!: Alert; + + @Input() + metadata!: AlertMetadata; + + progress: number | null = null; + + private isRemoved = false + + private countdownSubscription: Subscription | null = null; + + private readonly store = inject(Store); + + ngOnInit() { + if (!this.alert.isPersistent) { + this.progress = 0; + this.countdown(); + } + } + + ngOnDestroy(): void { + this.countdownSubscription?.unsubscribe(); + } + + @HostBinding('class') + get hostClass(): object { + return { + [`is-${this.alert.type}`]: true, + ['is-removed']: this.isRemoved, + } + } + + @HostListener('click') + handleClick() { + this.remove(); + } + + get icon(): string { + switch (this.alert.type) { + case AlertType.Success: + return 'checkmark' + case AlertType.Notice: + return 'info' + case AlertType.Warning: + return 'warn' + case AlertType.Error: + return 'failure' + } + } + + /** + * Decrement the alert's progress, and remove the alert when it's progress reaches 0. + * @private + */ + private countdown() { + this.countdownSubscription = interval(25).subscribe(() => { + const now = Date.now(); + const lifetimeMillis = now - this.metadata.createdAt.getTime(); + this.progress = 1 - lifetimeMillis / TOTAL_LIFETIME_MILLIS; + if (this.progress < 0) { + this.remove(); + } + }); + } + + /** + * Animate the disappearance of the alert, and remove it from the store after the animation has concluded. + * @private + */ + private remove() { + this.isRemoved = true; + this.countdownSubscription?.unsubscribe(); + this.countdownSubscription = null; + asyncScheduler.schedule(() => { + this.store.dispatch(hideAlert({ id: this.alert.id })); + }, 300); + } +} + +const TOTAL_LIFETIME_MILLIS = 5_000; diff --git a/libs/client-shared/src/lib/icons/checkmark.ts b/libs/client-shared/src/lib/icons/checkmark.ts new file mode 100644 index 00000000..744b6fb2 --- /dev/null +++ b/libs/client-shared/src/lib/icons/checkmark.ts @@ -0,0 +1,10 @@ +import { icon24x24 } from './icon'; + +export const checkmarkIcon = { + data: icon24x24( + '', + ), + name: 'checkmark' as const, +}; + +// diff --git a/libs/client-shared/src/lib/icons/failure.ts b/libs/client-shared/src/lib/icons/failure.ts new file mode 100644 index 00000000..f05ac193 --- /dev/null +++ b/libs/client-shared/src/lib/icons/failure.ts @@ -0,0 +1,6 @@ +import { icon24x24 } from './icon'; + +export const failureIcon = { + data: icon24x24(''), + name: 'failure' as const, +}; diff --git a/libs/client-shared/src/lib/icons/index.ts b/libs/client-shared/src/lib/icons/index.ts index d70abf19..dfe4ee4e 100644 --- a/libs/client-shared/src/lib/icons/index.ts +++ b/libs/client-shared/src/lib/icons/index.ts @@ -2,6 +2,7 @@ import { actionMenuIcon } from './action-menu'; import { assetsIcon } from './assets'; import { calendarIcon } from './calendar'; import { checkIcon } from './check'; +import { checkmarkIcon } from './checkmark'; import { closeIcon } from './close'; import { closeNavIcon } from './close-nav'; import { deleteIcon } from './delete'; @@ -10,6 +11,7 @@ import { editIcon } from './edit'; import { errorIcon } from './error'; import { errorFilledIcon } from './error-filled'; import { extLinkIcon } from './ext-link'; +import { failureIcon } from './failure'; import { favouriteIcon } from './favourite'; import { helpIcon } from './help'; import { infoIcon } from './info'; @@ -21,6 +23,7 @@ import { successIcon } from './success'; import { successFilledIcon } from './success-filled'; import { userManagementIcon } from './user-management'; import { viewExtendedIcon } from './view-extended'; +import { warnIcon } from './warn'; import { warningFilledIcon } from './warning-filled'; import { zoomMinusIcon } from './zoom-minus'; import { zoomOriginIcon } from './zoom-origin'; @@ -31,6 +34,7 @@ export const icons = [ assetsIcon, calendarIcon, checkIcon, + checkmarkIcon, closeIcon, closeNavIcon, deleteIcon, @@ -39,6 +43,7 @@ export const icons = [ errorIcon, errorFilledIcon, extLinkIcon, + failureIcon, favouriteIcon, helpIcon, infoFilledIcon, @@ -51,6 +56,7 @@ export const icons = [ userManagementIcon, viewExtendedIcon, warningFilledIcon, + warnIcon, zoomMinusIcon, zoomOriginIcon, zoomPlusIcon, diff --git a/libs/client-shared/src/lib/icons/warn.ts b/libs/client-shared/src/lib/icons/warn.ts new file mode 100644 index 00000000..9132eef5 --- /dev/null +++ b/libs/client-shared/src/lib/icons/warn.ts @@ -0,0 +1,8 @@ +import { icon24x24 } from './icon'; + +export const warnIcon = { + data: icon24x24( + '', + ), + name: 'warn' as const, +}; diff --git a/tsconfig.base.json b/tsconfig.base.json index 61e0fd67..a4d521a8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,6 +20,7 @@ "@asset-sg/asset-viewer": ["libs/asset-viewer/src/index.ts"], "@asset-sg/auth": ["libs/auth/src/index.ts"], "@asset-sg/client-shared": ["libs/client-shared/src/index.ts"], + "@asset-sg/client-shared/*": ["libs/client-shared/src/*"], "@asset-sg/core": ["libs/core/src/index.ts"], "@asset-sg/favourite": ["libs/favourite/src/index.ts"], "@asset-sg/profile": ["libs/profile/src/index.ts"],