diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html index e7f953e2e..be412ce9e 100644 --- a/client/src/app/app.component.html +++ b/client/src/app/app.component.html @@ -1,3 +1,4 @@
+
diff --git a/client/src/app/app.component.spec.ts b/client/src/app/app.component.spec.ts index 129bfe16c..c933cf019 100644 --- a/client/src/app/app.component.spec.ts +++ b/client/src/app/app.component.spec.ts @@ -25,7 +25,7 @@ describe('AppComponent', () => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app).toBeTruthy(); - tick(); + tick(1000); fixture.whenStable().then(() => { expect(servertimeService.startScheduler).toHaveBeenCalled(); }); diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 5b7b5c99f..e84c32610 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -1,7 +1,7 @@ import { Component, ApplicationRef } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { take, filter } from 'rxjs/operators'; +import { take, filter, auditTime } from 'rxjs/operators'; import { ConfigService } from './core/ui-services/config.service'; import { ConstantsService } from './core/core-services/constants.service'; @@ -15,6 +15,8 @@ import { DataStoreUpgradeService } from './core/core-services/data-store-upgrade import { UpdateService } from './core/ui-services/update.service'; import { PrioritizeService } from './core/core-services/prioritize.service'; import { PingService } from './core/core-services/ping.service'; +import { SpinnerService } from './core/ui-services/spinner.service'; +import { Router } from '@angular/router'; /** * Angular's global App Component @@ -48,10 +50,12 @@ export class AppComponent { translate: TranslateService, appRef: ApplicationRef, servertimeService: ServertimeService, + router: Router, operator: OperatorService, loginDataService: LoginDataService, constantsService: ConstantsService, // Needs to be started, so it can register itself to the WebsocketService themeService: ThemeService, + spinnerService: SpinnerService, countUsersService: CountUsersService, // Needed to register itself. configService: ConfigService, loadFontService: LoadFontService, @@ -70,13 +74,27 @@ export class AppComponent { translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en'); // change default JS functions this.overloadArrayToString(); + // Show the spinner initial + spinnerService.setVisibility(true, translate.instant('Loading data. Please wait...')); appRef.isStable .pipe( + // take only the stable state filter(s => s), take(1) ) .subscribe(() => servertimeService.startScheduler()); + + // Subscribe to hide the spinner if the application has changed. + appRef.isStable + .pipe( + filter(s => s), + auditTime(1000) + ) + .pipe(take(2)) + .subscribe(() => { + spinnerService.setVisibility(false); + }); } /** diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index e1bb0ab1c..cbf54f306 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -17,6 +17,7 @@ import { OpenSlidesTranslateModule } from './core/translate/openslides-translate // PWA import { ServiceWorkerModule } from '@angular/service-worker'; import { environment } from '../environments/environment'; +import { GlobalSpinnerComponent } from './site/global-spinner/global-spinner.component'; /** * Returns a function that returns a promis that will be resolved, if all apps are loaded. @@ -30,7 +31,7 @@ export function AppLoaderFactory(appLoadService: AppLoadService): () => Promise< * Global App Module. Keep it as clean as possible. */ @NgModule({ - declarations: [AppComponent], + declarations: [AppComponent, GlobalSpinnerComponent], imports: [ BrowserModule, HttpClientModule, diff --git a/client/src/app/core/ui-services/login-data.service.ts b/client/src/app/core/ui-services/login-data.service.ts index b699897aa..2d2403a25 100644 --- a/client/src/app/core/ui-services/login-data.service.ts +++ b/client/src/app/core/ui-services/login-data.service.ts @@ -13,6 +13,10 @@ export interface LoginData { privacy_policy: string; legal_notice: string; theme: string; + logo_web_header: { + path: string; + display_name: string; + }; } const LOGIN_DATA_STORAGE_KEY = 'LoginData'; @@ -61,6 +65,21 @@ export class LoginDataService { return this._theme.asObservable(); } + /** + * Holds the custom web header + */ + private readonly _logo_web_header = new BehaviorSubject<{ path: string; display_name: string }>({ + path: '', + display_name: '' + }); + + /** + * Returns an observable for the web header + */ + public get logo_web_header(): Observable<{ path: string; display_name: string }> { + return this._logo_web_header.asObservable(); + } + /** * Constructs this service. The config service is needed to update the privacy * policy and legal notice, when their config values change. @@ -83,6 +102,10 @@ export class LoginDataService { this._theme.next(value); this.storeLoginData(); }); + configService.get<{ path: string; display_name: string }>('logo_web_header').subscribe(value => { + this._logo_web_header.next(value); + this.storeLoginData(); + }); this.loadLoginData(); } @@ -120,7 +143,8 @@ export class LoginDataService { loginData = { privacy_policy: this._privacy_policy.getValue(), legal_notice: this._legal_notice.getValue(), - theme: this._theme.getValue() + theme: this._theme.getValue(), + logo_web_header: this._logo_web_header.getValue() }; } if (!this.OSStatus.isInHistoryMode) { diff --git a/client/src/app/core/ui-services/spinner.service.spec.ts b/client/src/app/core/ui-services/spinner.service.spec.ts new file mode 100644 index 000000000..ca42d5aef --- /dev/null +++ b/client/src/app/core/ui-services/spinner.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { SpinnerService } from './spinner.service'; + +describe('SpinnerService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: SpinnerService = TestBed.get(SpinnerService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/spinner.service.ts b/client/src/app/core/ui-services/spinner.service.ts new file mode 100644 index 000000000..8241eda9c --- /dev/null +++ b/client/src/app/core/ui-services/spinner.service.ts @@ -0,0 +1,40 @@ +// External imports +import { Injectable } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; + +/** + * Service for the `global-spinner.component` + * + * Handles the visibility of the global-spinner. + */ +@Injectable({ + providedIn: 'root' +}) +export class SpinnerService { + /** + * Subject, that holds the visibility and message. The component can observe this. + */ + private visibility: Subject<{ isVisible: boolean; text?: string }> = new Subject<{ + isVisible: boolean; + text?: string; + }>(); + + /** + * Function to change the visibility of the `global-spinner.component`. + * + * @param isVisible flag, if the spinner should be shown. + * @param text optional. If the spinner should show a message. + */ + public setVisibility(isVisible: boolean, text?: string): void { + setTimeout(() => this.visibility.next({ isVisible, text })); + } + + /** + * Function to get the visibility as observable. + * + * @returns class member `visibility`. + */ + public getVisibility(): Observable<{ isVisible: boolean; text?: string }> { + return this.visibility; + } +} diff --git a/client/src/app/core/ui-services/theme.service.ts b/client/src/app/core/ui-services/theme.service.ts index c5e363085..e97de03ad 100644 --- a/client/src/app/core/ui-services/theme.service.ts +++ b/client/src/app/core/ui-services/theme.service.ts @@ -9,6 +9,26 @@ import { LoginDataService } from './login-data.service'; providedIn: 'root' }) export class ThemeService { + /** + * Constant, that describes the default theme class. + */ + public static DEFAULT_THEME = 'openslides-theme'; + + /** + * Constant path of the logo with dark colors for bright themes. + */ + public static STANDARD_LOGO = '/assets/img/openslides-logo-h.svg'; + + /** + * Constant path of the logo with white colors for dark themes. + */ + public static STANDARD_LOGO_DARK_THEME = '/assets/img/openslides-logo-h-dark-transparent.svg'; + + /** + * Holds the current theme as member. + */ + private currentTheme: string; + /** * Here it will subscribe to the observer from login data service. The stheme is part of * the login data, so get it from there and not from the config. This service will @@ -21,13 +41,31 @@ export class ThemeService { if (!newTheme) { return; } + this.currentTheme = newTheme; const classList = document.getElementsByTagName('body')[0].classList; // Get the classlist of the body. const toRemove = Array.from(classList).filter((item: string) => item.includes('theme')); if (toRemove.length) { classList.remove(...toRemove); // Remove all old themes. } - classList.add(newTheme); // Add the new theme. + classList.add(newTheme, ThemeService.DEFAULT_THEME); // Add the new theme. }); } + + /** + * Returns the logo relative to the used theme. + * + * @param shouldDefault If this method should return the default logo. + * + * @returns the path to the logo. + */ + public getLogoRelativeToTheme(shouldDefault?: boolean): string { + if (this.currentTheme) { + return this.currentTheme.includes('dark') && !shouldDefault + ? ThemeService.STANDARD_LOGO_DARK_THEME + : ThemeService.STANDARD_LOGO; + } else { + return null; + } + } } diff --git a/client/src/app/shared/components/logo/logo.component.html b/client/src/app/shared/components/logo/logo.component.html index cfdf83f4d..3cf3948ba 100644 --- a/client/src/app/shared/components/logo/logo.component.html +++ b/client/src/app/shared/components/logo/logo.component.html @@ -1,3 +1,4 @@ -
- +
+ +
diff --git a/client/src/app/shared/components/logo/logo.component.scss b/client/src/app/shared/components/logo/logo.component.scss index 5d3fec5f5..e7580d8a2 100644 --- a/client/src/app/shared/components/logo/logo.component.scss +++ b/client/src/app/shared/components/logo/logo.component.scss @@ -2,6 +2,7 @@ img { max-width: 100%; height: auto; max-height: 100%; + margin: 0 auto; } .logo-container { diff --git a/client/src/app/shared/components/logo/logo.component.ts b/client/src/app/shared/components/logo/logo.component.ts index fa2b0a5d9..3d6c21832 100644 --- a/client/src/app/shared/components/logo/logo.component.ts +++ b/client/src/app/shared/components/logo/logo.component.ts @@ -1,63 +1,27 @@ -import { Component, OnInit, Input } from '@angular/core'; +import { Component, Input, OnInit, OnDestroy } from '@angular/core'; -import { MediaManageService } from 'app/core/ui-services/media-manage.service'; -import { ConfigService } from 'app/core/ui-services/config.service'; +import { ThemeService } from 'app/core/ui-services/theme.service'; +import { LoginDataService } from 'app/core/ui-services/login-data.service'; +import { Subscription } from 'rxjs'; /** - * Reusable Logo component for Apps. - * - * Following actions are possible: - * * "logo_projector_main" - * * "logo_projector_header" - * * "logo_web_header" - * * "logo_pdf_header_L" - * * "logo_pdf_header_R" - * * "logo_pdf_footer_L" - * * "logo_pdf_footer_R" - * * "logo_pdf_ballot_paper" - * - * ## Examples: - * - * ### Usage of the selector: - * - * ```html - * - * - * ``` - * - * Sidenote: The footer variable is optional. Only if you want - * alternating logos, i.E. in the sidenav. the Alignment is also - * optional. + * Component to hold the logo for the app. */ @Component({ selector: 'os-logo', templateUrl: './logo.component.html', styleUrls: ['./logo.component.scss'] }) -export class LogoComponent implements OnInit { +export class LogoComponent implements OnInit, OnDestroy { /** - * Constant path of the logo with dark colors for bright themes + * Local variable to hold the path for a custom web header. */ - public static STANDARD_LOGO = '/assets/img/openslides-logo-h.svg'; + private logoPath: string; /** - * Constant path of the logo with white colors for dark themes + * Local variable to hold the subscription to unsubscribe if exists. */ - public static STANDARD_LOGO_DARK_THEME = '/assets/img/openslides-logo-h-dark-transparent.svg'; - - /** - * Holds the actions for logos. Updated via an observable - */ - public logoActions: string[]; - - /** - * decides based on the actionString how to display the logo - */ - @Input() - public inputAction: string; + private logoSubscription: Subscription; /** * determines if the current picture is displayed in the footer. @@ -66,117 +30,46 @@ export class LogoComponent implements OnInit { @Input() public footer = false; - /** - * influences text-alignment in the .logo-container css class - */ - @Input() - public alignment = 'center'; /** * The constructor * - * @param mmservice The Media Manage Service - * @param configService The ConfigService to subscribe to theme-changes + * @param loginDataService Reference to the `LoginDataService` + * @param themeService Reference to the `ThemeService` */ - public constructor(private mmservice: MediaManageService, private configService: ConfigService) {} + public constructor(private loginDataService: LoginDataService, private themeService: ThemeService) {} /** - * Initialization function + * On init method */ public ngOnInit(): void { - this.mmservice.getLogoActions().subscribe(action => { - this.logoActions = action; + this.logoSubscription = this.loginDataService.logo_web_header.subscribe(nextLogo => { + if (nextLogo) { + this.logoPath = nextLogo.path; + } }); } /** - * gets the image based on the inputAction and location. - * Possible inputActions are in the class description. + * On destroy method + */ + public ngOnDestroy(): void { + if (this.logoSubscription) { + this.logoSubscription.unsubscribe(); + this.logoSubscription = null; + } + } + + /** + * Get the image based on custom images and footer. + * If a custom image is set and this component is displayed as footer or there is no custom image, then the OpenSlides logo is used. * * @returns path to image */ - public getImage(): string { - if (this.footer) { - const path = this.getFooterImage(this.inputAction); - return path; + public getImage(shouldDefault?: boolean): string { + if ((!this.logoPath && !this.footer) || (!!this.logoPath && this.footer)) { + return this.themeService.getLogoRelativeToTheme(shouldDefault); } else { - const path = this.getHeaderImage(this.inputAction, this.alignment); - return path; - } - } - - /** - * Check if the user uses a dark theme or a 'bright' theme. - * In relation to the theme this will return the corresponding imagepath. - * - * @returns path of the image corresponding to the chosen theme. - */ - protected getImagePathRelatedToTheme(): string { - const theme = this.configService.instant('openslides_theme'); - if (theme) { - return theme.includes('dark') ? LogoComponent.STANDARD_LOGO_DARK_THEME : LogoComponent.STANDARD_LOGO; - } else { - return LogoComponent.STANDARD_LOGO; - } - } - - /** - * gets the header image based on logo action - * - * @param logoAction the logo action to be used - * @param alignment the alignment of the logo (optional) - * @returns path to image - */ - protected getHeaderImage(logoAction: string, alignment: string = 'center'): string { - if (alignment !== 'center') { - this.setAlignment(alignment); - } - let path = ''; - /* check if datastore is loaded and custom logo can be read */ - if (this.logoActions === undefined) { - return ''; - } - if (this.mmservice !== undefined) { - if (this.mmservice.isImageConfigObject(this.mmservice.getMediaConfig(logoAction))) { - const imageConfig = this.mmservice.getMediaConfig(logoAction); - path = imageConfig.path; - } - } - if (path === '') { - path = this.getImagePathRelatedToTheme(); - } - return path; - } - - /** - * Changes the alignment from center to either 'left' or 'right' - * - * @param alignment either 'right' or 'left' - */ - private setAlignment(alignment: string): void { - if (alignment === 'left' || alignment === 'right') { - const cssLogoContainer = document.getElementsByClassName('logo-container') as HTMLCollectionOf; - if (cssLogoContainer.length !== 0) { - cssLogoContainer[0].style.textAlign = alignment; - } - } - } - - /** - * Returns the image-path for the footer - * - * @param logoAction the logo action to be used - * @returns '' if no logo is set and path to standard logo if a custom - * logo was set - */ - protected getFooterImage(logoAction: string): string { - if ( - this.getHeaderImage(logoAction) === LogoComponent.STANDARD_LOGO || - this.getHeaderImage(logoAction) === LogoComponent.STANDARD_LOGO_DARK_THEME || - this.getHeaderImage(logoAction) === '' - ) { - return ''; - } else { - return this.getImagePathRelatedToTheme(); + return this.logoPath; } } } diff --git a/client/src/app/site/global-spinner/global-spinner.component.html b/client/src/app/site/global-spinner/global-spinner.component.html new file mode 100644 index 000000000..6e1791159 --- /dev/null +++ b/client/src/app/site/global-spinner/global-spinner.component.html @@ -0,0 +1,11 @@ +
+
+
+
+
{{ text }}
+
+
+
+
diff --git a/client/src/app/site/global-spinner/global-spinner.component.scss b/client/src/app/site/global-spinner/global-spinner.component.scss new file mode 100644 index 000000000..213c9e135 --- /dev/null +++ b/client/src/app/site/global-spinner/global-spinner.component.scss @@ -0,0 +1,83 @@ +@import '~@angular/material/theming'; + +@mixin os-global-spinner-theme($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, warn); + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + + $contrast-primary: map-get($primary, contrast); + $contrast-accent: map-get($accent, contrast); + + .global-spinner-component, + .backdrop, + .spinner-container { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 999; + } + + .global-spinner-component { + position: fixed; + + .spinner-container { + display: flex; + justify-content: center; + align-items: center; + + .spinner { + position: absolute; + top: 50%; + left: 50%; + margin: -136px 0 0 -53px; + + height: 100px; + width: 100px; + border: 6px solid #000; + border-radius: 100%; + opacity: 0.2; + + animation: rotation 1s infinite linear; + + &:before { + position: absolute; + top: -6px; + left: -6px; + + content: ''; + display: block; + height: 100%; + width: 100%; + border-radius: 100%; + border-style: solid; + border-width: 6px; + border-color: white transparent transparent; + } + + @keyframes rotation { + from { + transform: rotate(0deg); + } + to { + transform: rotate(359deg); + } + } + } + + .text { + text-align: center; + color: white; + font-size: 1.4rem; + } + } + .backdrop { + z-index: 899; + background-color: #303030; + opacity: 0.8; + } + } +} diff --git a/client/src/app/site/global-spinner/global-spinner.component.spec.ts b/client/src/app/site/global-spinner/global-spinner.component.spec.ts new file mode 100644 index 000000000..4cb83cc0e --- /dev/null +++ b/client/src/app/site/global-spinner/global-spinner.component.spec.ts @@ -0,0 +1,24 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GlobalSpinnerComponent } from './global-spinner.component'; + +describe('GlobalSpinnerComponent', () => { + let component: GlobalSpinnerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [GlobalSpinnerComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GlobalSpinnerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/global-spinner/global-spinner.component.ts b/client/src/app/site/global-spinner/global-spinner.component.ts new file mode 100644 index 000000000..dab096a3c --- /dev/null +++ b/client/src/app/site/global-spinner/global-spinner.component.ts @@ -0,0 +1,74 @@ +// External imports +import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { Subscription } from 'rxjs'; + +// Internal imports +import { SpinnerService } from 'app/core/ui-services/spinner.service'; + +/** + * Component for the global spinner. + */ +@Component({ + selector: 'os-global-spinner', + templateUrl: './global-spinner.component.html', + styleUrls: ['./global-spinner.component.scss'] +}) +export class GlobalSpinnerComponent implements OnInit, OnDestroy { + /** + * Text, which will be shown if the spinner is shown. + */ + public text: string; + + /** + * Flag, that defines when the spinner is shown. + */ + public isVisible = false; + + /** + * Subscription for the service to handle the visibility and text for the spinner. + */ + private spinnerSubscription: Subscription; + + /** + * Constant string as default message when the spinner is shown. + */ + private LOADING = 'Loading data. Please wait...'; + + /** + * + * @param spinnerService Reference to the service for this spinner. + * @param translate Service to get translations for the messages. + * @param detector Service to manual initiate a change of the UI. + */ + public constructor(private spinnerService: SpinnerService, private detector: ChangeDetectorRef) {} + + /** + * Init method + */ + public ngOnInit(): void { + this.spinnerSubscription = this.spinnerService // subscribe to the service. + .getVisibility() + .subscribe((value: { isVisible: boolean; text: string }) => { + this.isVisible = value.isVisible; + this.text = value.text; + + if (!this.text) { + this.text = this.LOADING; + } + this.detector.detectChanges(); + }); + } + + /** + * Destroy method + * + * Deletes the subscription and marks the spinner as invisible. + */ + public ngOnDestroy(): void { + if (this.spinnerSubscription) { + this.spinnerSubscription.unsubscribe(); + this.isVisible = false; + } + this.spinnerSubscription = null; + } +} diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.html b/client/src/app/site/login/components/login-mask/login-mask.component.html index 8817b4a7d..3cc0e2b86 100644 --- a/client/src/app/site/login/components/login-mask/login-mask.component.html +++ b/client/src/app/site/login/components/login-mask/login-mask.component.html @@ -1,6 +1,4 @@
- -