Merge pull request #4578 from GabrielInTheWorld/theming

Implements a mechanism for a fallback theme
This commit is contained in:
Emanuel Schütze 2019-05-07 21:13:55 +02:00 committed by GitHub
commit 51b4b6aba6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 702 additions and 214 deletions

View File

@ -1,3 +1,4 @@
<div class="content">
<router-outlet></router-outlet>
<os-global-spinner></os-global-spinner>
</div>

View File

@ -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();
});

View File

@ -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);
});
}
/**

View File

@ -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,

View File

@ -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) {

View File

@ -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();
});
});

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -1,3 +1,4 @@
<div *ngIf="getImage() != ''" class="logo-container">
<img [src]="getImage()">
<div *ngIf="getImage()" class="logo-container">
<img [src]="getImage('true')" class="default" />
<img [src]="getImage()" class="dark"/>
</div>

View File

@ -2,6 +2,7 @@ img {
max-width: 100%;
height: auto;
max-height: 100%;
margin: 0 auto;
}
.logo-container {

View File

@ -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
* <os-logo
* inputAction="logo_projector_main"
* [footer]="false"
* [alignment]="right">
* </os-logo>
* ```
*
* 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<string>('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<HTMLElement>;
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;
}
}
}

View File

@ -0,0 +1,11 @@
<div
*ngIf="isVisible"
class="global-spinner-component">
<div class="spinner-container">
<div>
<div class="spinner"></div>
<div class="text">{{ text }}</div>
</div>
</div>
<div class="backdrop"></div>
</div>

View File

@ -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;
}
}
}

View File

@ -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<GlobalSpinnerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [GlobalSpinnerComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(GlobalSpinnerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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;
}
}

View File

@ -1,6 +1,4 @@
<div class="form-wrapper">
<!-- Spinner -->
<mat-spinner *ngIf="inProcess"></mat-spinner>
<!-- Install notice -->
<div class="login-container" *ngIf="installationNotice">

View File

@ -13,6 +13,7 @@ import { environment } from 'environments/environment';
import { LoginDataService, LoginData } from 'app/core/ui-services/login-data.service';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { HttpService } from 'app/core/core-services/http.service';
import { SpinnerService } from 'app/core/ui-services/spinner.service';
interface LoginDataWithInfoText extends LoginData {
info_text?: string;
@ -54,11 +55,6 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
*/
public parentErrorStateMatcher = new ParentErrorStateMatcher();
/**
* Show the Spinner if validation is in process
*/
public inProcess = false;
public operatorSubscription: Subscription | null;
/**
@ -71,6 +67,7 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
* @param httpService used to get information before the login
* @param OpenSlides The Service for OpenSlides
* @param loginDataService provide information about the legal notice and privacy policy
* @param spinnerService Service to show the spinner when the user is signing in
*/
public constructor(
title: Title,
@ -81,9 +78,12 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private httpService: HttpService,
private loginDataService: LoginDataService
private loginDataService: LoginDataService,
private spinnerService: SpinnerService
) {
super(title, translate);
// Hide the spinner if the user is at `login-mask`
spinnerService.setVisibility(false);
this.createForm();
}
@ -149,8 +149,8 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
* Send username and password to the {@link AuthService}
*/
public async formLogin(): Promise<void> {
this.spinnerService.setVisibility(true, this.translate.instant('Loading data. Please wait...'));
this.loginErrorMsg = '';
this.inProcess = true;
try {
await this.authService.login(this.loginForm.value.username, this.loginForm.value.password, () => {
this.clearOperatorSubscription(); // We take control, not the subscription.
@ -161,7 +161,7 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
});
this.loginErrorMsg = e;
}
this.inProcess = false;
this.spinnerService.setVisibility(false);
}
/**

View File

@ -18,6 +18,7 @@ import { TreeService } from 'app/core/ui-services/tree.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ViewMotion } from '../models/view-motion';
import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflow-repository.service';
import { SpinnerService } from 'app/core/ui-services/spinner.service';
/**
* Contains all multiselect actions for the motion list view.
@ -26,6 +27,8 @@ import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflo
providedIn: 'root'
})
export class MotionMultiselectService {
private messageForSpinner = 'Motions are in process. Please wait...';
/**
* Does nothing.
*
@ -42,6 +45,7 @@ export class MotionMultiselectService {
* @param httpService
* @param treeService
* @param personalNoteService
* @param spinnerService to show a spinner when http-requests are made.
*/
public constructor(
private repo: MotionRepositoryService,
@ -56,7 +60,8 @@ export class MotionMultiselectService {
private motionBlockRepo: MotionBlockRepositoryService,
private httpService: HttpService,
private treeService: TreeService,
private personalNoteService: PersonalNoteService
private personalNoteService: PersonalNoteService,
private spinnerService: SpinnerService
) {}
/**
@ -67,9 +72,19 @@ export class MotionMultiselectService {
public async delete(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete all selected motions?');
if (await this.promptService.open(title, null)) {
let i = 0;
for (const motion of motions) {
++i;
const message =
this.translate.instant(this.messageForSpinner) +
`\n${i} ` +
this.translate.instant('of') +
` ${motions.length}`;
this.spinnerService.setVisibility(true, message);
await this.repo.delete(motion);
}
this.spinnerService.setVisibility(false);
}
}
@ -102,7 +117,13 @@ export class MotionMultiselectService {
}));
const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) {
await this.repo.setMultiState(motions, selectedChoice.items as number);
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
await this.repo.setMultiState(motions, selectedChoice.items as number).catch(error => {
this.spinnerService.setVisibility(false);
throw error;
});
this.spinnerService.setVisibility(false);
}
}
@ -127,9 +148,18 @@ export class MotionMultiselectService {
id: motion.id,
recommendation: selectedChoice.action ? 0 : (selectedChoice.items as number)
}));
await this.httpService.post('/rest/motions/motion/manage_multiple_recommendation/', {
motions: requestData
});
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
await this.httpService
.post('/rest/motions/motion/manage_multiple_recommendation/', {
motions: requestData
})
.catch(error => {
this.spinnerService.setVisibility(false);
throw error;
});
this.spinnerService.setVisibility(false);
}
}
@ -149,12 +179,21 @@ export class MotionMultiselectService {
clearChoice
);
if (selectedChoice) {
let i = 0;
for (const motion of motions) {
++i;
const message =
this.translate.instant(this.messageForSpinner) +
`\n${i} ` +
this.translate.instant('of') +
` ${motions.length}`;
this.spinnerService.setVisibility(true, message);
await this.repo.update(
{ category_id: selectedChoice.action ? null : (selectedChoice.items as number) },
motion
);
}
this.spinnerService.setVisibility(false);
}
}
@ -174,26 +213,33 @@ export class MotionMultiselectService {
true,
choices
);
if (selectedChoice && selectedChoice.action === choices[0]) {
const requestData = motions.map(motion => {
let submitterIds = [...motion.sorted_submitters_id, ...(selectedChoice.items as number[])];
submitterIds = submitterIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates
return {
id: motion.id,
submitters: submitterIds
};
});
await this.httpService.post('/rest/motions/motion/manage_multiple_submitters/', { motions: requestData });
} else if (selectedChoice && selectedChoice.action === choices[1]) {
const requestData = motions.map(motion => {
const submitterIdsToRemove = selectedChoice.items as number[];
const submitterIds = motion.sorted_submitters_id.filter(id => !submitterIdsToRemove.includes(id));
return {
id: motion.id,
submitters: submitterIds
};
});
if (selectedChoice) {
let requestData = null;
if (selectedChoice.action === choices[0]) {
requestData = motions.map(motion => {
let submitterIds = [...motion.sorted_submitters_id, ...(selectedChoice.items as number[])];
submitterIds = submitterIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates
return {
id: motion.id,
submitters: submitterIds
};
});
// await this.httpService.post('/rest/motions/motion/manage_multiple_submitters/', { motions: requestData });
} else if (selectedChoice.action === choices[1]) {
requestData = motions.map(motion => {
const submitterIdsToRemove = selectedChoice.items as number[];
const submitterIds = motion.sorted_submitters_id.filter(id => !submitterIdsToRemove.includes(id));
return {
id: motion.id,
submitters: submitterIds
};
});
}
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
await this.httpService.post('/rest/motions/motion/manage_multiple_submitters/', { motions: requestData });
this.spinnerService.setVisibility(false);
}
}
@ -215,34 +261,41 @@ export class MotionMultiselectService {
true,
choices
);
if (selectedChoice && selectedChoice.action === choices[0]) {
const requestData = motions.map(motion => {
let tagIds = [...motion.tags_id, ...(selectedChoice.items as number[])];
tagIds = tagIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates
return {
id: motion.id,
tags: tagIds
};
});
await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
} else if (selectedChoice && selectedChoice.action === choices[1]) {
const requestData = motions.map(motion => {
const tagIdsToRemove = selectedChoice.items as number[];
const tagIds = motion.tags_id.filter(id => !tagIdsToRemove.includes(id));
return {
id: motion.id,
tags: tagIds
};
});
await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
} else if (selectedChoice && selectedChoice.action === choices[2]) {
const requestData = motions.map(motion => {
return {
id: motion.id,
tags: []
};
});
if (selectedChoice) {
let requestData = null;
if (selectedChoice.action === choices[0]) {
requestData = motions.map(motion => {
let tagIds = [...motion.tags_id, ...(selectedChoice.items as number[])];
tagIds = tagIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates
return {
id: motion.id,
tags: tagIds
};
});
// await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
} else if (selectedChoice.action === choices[1]) {
requestData = motions.map(motion => {
const tagIdsToRemove = selectedChoice.items as number[];
const tagIds = motion.tags_id.filter(id => !tagIdsToRemove.includes(id));
return {
id: motion.id,
tags: tagIds
};
});
// await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
} else if (selectedChoice.action === choices[2]) {
requestData = motions.map(motion => {
return {
id: motion.id,
tags: []
};
});
}
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
this.spinnerService.setVisibility(false);
}
}
@ -262,10 +315,19 @@ export class MotionMultiselectService {
clearChoice
);
if (selectedChoice) {
let i = 0;
for (const motion of motions) {
++i;
const message =
this.translate.instant(this.messageForSpinner) +
`\n${i} ` +
this.translate.instant('of') +
` ${motions.length}`;
this.spinnerService.setVisibility(true, message);
const blockId = selectedChoice.action ? null : (selectedChoice.items as number);
await this.repo.update({ motion_block_id: blockId }, motion);
}
this.spinnerService.setVisibility(false);
}
}
@ -317,8 +379,11 @@ export class MotionMultiselectService {
];
const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice && motions.length) {
const message = this.translate.instant(`I have ${motions.length} favorite motions. Please wait...`);
const star = (selectedChoice.items as number) === choices[0].id;
this.spinnerService.setVisibility(true, message);
await this.personalNoteService.bulkSetStar(motions, star);
this.spinnerService.setVisibility(false);
}
}
}

View File

@ -20,7 +20,7 @@
<div class="nav-toolbar">
<!-- logo -->
<a routerLink="/" (click)="toggleSideNav()">
<os-logo class="os-logo-container" inputAction="logo_web_header" [footer]="false"></os-logo>
<os-logo class="os-logo-container" [footer]="false"></os-logo>
</a>
</div>
@ -130,7 +130,9 @@
<small><os-copyright-sign></os-copyright-sign>&nbsp;Copyright by OpenSlides</small>
</span>
</a>
<div class="os-footer-logo-container"><os-logo inputAction="logo_web_header" footer="true"> </os-logo></div>
<div class="os-footer-logo-container">
<os-logo [footer]="true"></os-logo>
</div>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>

View File

@ -0,0 +1,75 @@
$openslides-developer-primary: (
50 : #000000,
100 : #000000,
200 : #000000,
300 : #000000,
400 : #000000,
500 : #000000,
600 : #000000,
700 : #000000,
800 : #000000,
900 : #000000,
A100 : #000000,
A200 : #000000,
A400 : #000000,
A700 : #000000,
contrast: (
50 : #000000,
100 : #000000,
200 : #000000,
300 : #000000,
400 : #000000,
500 : #000000,
600 : #000000,
700 : #000000,
800 : #000000,
900 : #000000,
A100 : #000000,
A200 : #000000,
A400 : #000000,
A700 : #000000,
)
);
$openslides-developer-accent: (
50 : #f4f9e1,
100 : #e5f0b3,
200 : #d3e781,
300 : #c1dd4e,
400 : #b4d528,
500 : #a7ce02,
600 : #9fc902,
700 : #96c201,
800 : #8cbc01,
900 : #7cb001,
A100 : #f2ffda,
A200 : #e1ffa7,
A400 : #cfff74,
A700 : #c6ff5a,
contrast: (
50 : #000000,
100 : #000000,
200 : #000000,
300 : #000000,
400 : #000000,
500 : #000000,
600 : #000000,
700 : #000000,
800 : #000000,
900 : #000000,
A100 : #000000,
A200 : #000000,
A400 : #000000,
A700 : #000000,
)
);
$openslides-primary: mat-palette($openslides-developer-primary);
$openslides-accent: mat-palette($openslides-developer-accent);
$openslides-warn: mat-palette($mat-pink);
$openslides-developer-theme: mat-dark-theme(
$openslides-primary,
$openslides-accent,
$openslides-warn
);

View File

@ -0,0 +1,40 @@
$openslides-primary-dark: (
50 : #e0e0e0,
100 : #b3b3b3,
200 : #808080,
300 : #4d4d4d,
400 : #262626,
500 : #000000,
600 : #000000,
700 : #000000,
800 : #000000,
900 : #000000,
A100 : #a6a6a6,
A200 : #8c8c8c,
A400 : #737373,
A700 : #666666,
contrast: (
50 : #000000,
100 : #000000,
200 : #000000,
300 : #ffffff,
400 : #ffffff,
500 : #ffffff,
600 : #ffffff,
700 : #ffffff,
800 : #ffffff,
900 : #ffffff,
A100 : #000000,
A200 : #000000,
A400 : #ffffff,
A700 : #ffffff,
)
);
$openslides-primary: mat-palette($openslides-primary-dark, 500, 700, 900);
$openslides-general-theme: mat-light-theme(
$openslides-primary,
$openslides-primary,
$openslides-primary
);

View File

@ -13,7 +13,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
</head>
<body class="openslides-theme">
<body class="general-theme">
<os-root></os-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body>

View File

@ -3,9 +3,11 @@
@include mat-core();
/** Import brand theme */
@import './assets/styles/openslides-general-theme.scss';
@import './assets/styles/openslides-theme.scss';
@import './assets/styles/openslides-dark-theme.scss';
@import './assets/styles/openslides-green-theme.scss';
@import './assets/styles/openslides-developer-theme.scss';
/** Import the component-related style sheets here */
@import './app/site/site.component.scss-theme.scss';
@ -13,6 +15,7 @@
@import './app/shared/components/projector-button/projector-button.component.scss';
@import './app/site/agenda/components/list-of-speakers/list-of-speakers.component.scss-theme.scss';
@import './app/shared/components/sorting-tree/sorting-tree.component.scss';
@import './app/site/global-spinner/global-spinner.component.scss';
/** fonts */
@import './assets/styles/fonts.scss';
@ -25,6 +28,7 @@
@include os-projector-button-style($theme);
@include os-list-of-speakers-style($theme);
@include os-sorting-tree-style($theme);
@include os-global-spinner-theme($theme);
/** More components are added here */
}
@ -34,10 +38,34 @@
/** Load projector specific SCSS values */
@import './assets/styles/projector.scss';
.general-theme {
@include os-global-spinner-theme($openslides-general-theme);
}
/** Define the classes to switch between themes */
.openslides-theme {
@include angular-material-theme($openslides-theme);
@include openslides-components-theme($openslides-theme);
.logo-container {
img.dark {
display: none;
}
img.default {
display: inherit;
}
}
}
.openslides-dark-theme, .openslides-developer-dark-theme {
.logo-container {
img.dark {
display: inherit;
}
img.default {
display: none;
}
}
}
.openslides-dark-theme {
@ -50,17 +78,71 @@
@include openslides-components-theme($openslides-green-theme);
}
.openslides-developer-dark-theme {
@include angular-material-theme($openslides-developer-theme);
@include openslides-components-theme($openslides-developer-theme);
* {
font-family: monospace !important;
color: mat-color(map-get($openslides-developer-theme, accent));
}
os-site {
.active mat-icon, .active span {
color: mat-color(map-get($openslides-developer-theme, accent));
}
}
.custom-table-header {
background: #000;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mat-toolbar.sticky-toolbar {
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mat-drawer-inner-container {
background: #000;
}
.mat-drawer-inner-container > * {
background: #000 !important;
z-index: 3;
.os-site.nav-toolbar {
background: #000;
}
}
.mat-drawer-content > * {
background: #000;
}
mat-icon {
font-family: 'Material Icons' !important;
}
.mat-mini-fab.projector-active {
background-color: mat-color(map-get($openslides-developer-theme, warn));
}
.mce-ico {
font-family: 'tinymce', Arial !important;
}
}
/** Define the general style-rules */
* {
font-family: OSFont, Fira Sans, Roboto, Arial, Helvetica, sans-serif;
}
.mat-toolbar h2,
.mat-dialog-title {
font-family: OSFont, Fira Sans, Roboto, Arial, Helvetica, sans-serif !important;
font-family: OSFont, Fira Sans, Roboto, Arial, Helvetica, sans-serif;
}
mat-icon {
font-family: 'Material Icons Baseline';
font-family: 'Material Icons' !important;
}
body {

View File

@ -121,6 +121,10 @@ def get_config_variables():
{"value": "openslides-theme", "display_name": "OpenSlides Default"},
{"value": "openslides-dark-theme", "display_name": "OpenSlides Dark"},
{"value": "openslides-green-theme", "display_name": "OpenSlides Green"},
{
"value": "openslides-developer-dark-theme",
"display_name": "OpenSlides Developer",
},
),
weight=141,
group="General",

View File

@ -562,6 +562,7 @@ class UserLoginView(WhoAmIDataView):
context["legal_notice"] = config["general_event_legal_notice"]
# Add the theme, so the loginpage is themed correctly
context["theme"] = config["openslides_theme"]
context["logo_web_header"] = config["logo_web_header"]
else:
# self.request.method == 'POST'
context.update(self.get_whoami_data())